2020-11-06 03:08:21 +01:00
|
|
|
#! /usr/bin/env python3
|
|
|
|
import argparse
|
2021-04-16 01:18:33 +02:00
|
|
|
import io
|
2020-11-27 18:47:48 +01:00
|
|
|
import json
|
2021-05-21 23:47:26 +02:00
|
|
|
import math
|
2020-11-06 03:08:21 +01:00
|
|
|
import os
|
|
|
|
import os.path
|
|
|
|
import sys
|
2020-11-06 17:18:09 +01:00
|
|
|
import textwrap
|
2020-11-23 21:56:05 +01:00
|
|
|
from PIL import Image, ImageDraw # type: ignore
|
2021-08-12 17:57:02 +02:00
|
|
|
from typing import Any, Dict, List, Optional, Tuple, TypeVar
|
2020-11-06 03:08:21 +01:00
|
|
|
|
2021-05-21 23:31:13 +02:00
|
|
|
from bemani.format.afp import TXP2File, Shape, SWF, Frame, Tag, AP2DoActionTag, AP2PlaceObjectTag, AP2DefineSpriteTag, AFPRenderer, Color, Matrix
|
2021-04-16 01:18:33 +02:00
|
|
|
from bemani.format import IFS
|
2020-11-30 19:16:02 +01:00
|
|
|
|
2020-11-06 03:08:21 +01:00
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
def write_bytecode(swf: SWF, directory: str, *, verbose: bool) -> None:
|
2021-05-10 02:12:54 +02:00
|
|
|
# Actually place the files down.
|
|
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
|
|
|
|
# Buffer for where the decompiled data goes.
|
|
|
|
buff: List[str] = []
|
|
|
|
lut: Dict[str, int] = {}
|
|
|
|
|
|
|
|
def bytecode_from_frames(frames: List[Frame]) -> None:
|
|
|
|
for frame in frames:
|
|
|
|
for tag in frame.imported_tags:
|
|
|
|
if tag.init_bytecode:
|
|
|
|
buff.append(tag.init_bytecode.decompile(verbose=verbose))
|
|
|
|
|
|
|
|
def bytecode_from_tags(tags: List[Tag]) -> None:
|
|
|
|
for tag in tags:
|
|
|
|
if isinstance(tag, AP2DoActionTag):
|
|
|
|
buff.append(tag.bytecode.decompile(verbose=verbose))
|
|
|
|
elif isinstance(tag, AP2PlaceObjectTag):
|
|
|
|
for _, triggers in tag.triggers.items():
|
|
|
|
for trigger in triggers:
|
|
|
|
buff.append(trigger.decompile(verbose=verbose))
|
|
|
|
elif isinstance(tag, AP2DefineSpriteTag):
|
2021-05-11 00:26:46 +02:00
|
|
|
lut.update(tag.labels)
|
2021-05-10 02:12:54 +02:00
|
|
|
bytecode_from_frames(tag.frames)
|
|
|
|
bytecode_from_tags(tag.tags)
|
|
|
|
|
2021-05-11 00:26:46 +02:00
|
|
|
lut.update(swf.labels)
|
2021-05-10 02:12:54 +02:00
|
|
|
bytecode_from_frames(swf.frames)
|
|
|
|
bytecode_from_tags(swf.tags)
|
|
|
|
|
2021-05-11 00:26:46 +02:00
|
|
|
# If we have frame labels, put them at the top as global defines.
|
2021-05-10 02:12:54 +02:00
|
|
|
if lut:
|
|
|
|
buff = [
|
|
|
|
os.linesep.join([
|
2021-08-12 17:57:37 +02:00
|
|
|
'// Defined frame labels from animation container, as used for frame lookups.',
|
2021-05-10 02:12:54 +02:00
|
|
|
'FRAME_LUT = {',
|
|
|
|
*[f" {name!r}: {frame}," for name, frame in lut.items()],
|
|
|
|
'};',
|
|
|
|
]),
|
|
|
|
*buff,
|
|
|
|
]
|
|
|
|
|
|
|
|
# Now, write it out.
|
|
|
|
filename = os.path.join(directory, swf.exported_name) + ".code"
|
|
|
|
print(f"Writing code to {filename}...")
|
|
|
|
with open(filename, "wb") as bfp:
|
|
|
|
bfp.write(f"{os.linesep}{os.linesep}".join(buff).encode('utf-8'))
|
|
|
|
|
|
|
|
|
2021-05-25 04:01:36 +02:00
|
|
|
def parse_intlist(data: str) -> List[int]:
|
|
|
|
ints: List[int] = []
|
|
|
|
|
|
|
|
for chunk in data.split(","):
|
|
|
|
chunk = chunk.strip()
|
|
|
|
if '-' in chunk:
|
|
|
|
start, end = chunk.split('-', 1)
|
|
|
|
start_int = int(start.strip())
|
|
|
|
end_int = int(end.strip())
|
|
|
|
ints.extend(range(start_int, end_int + 1))
|
|
|
|
else:
|
|
|
|
ints.append(int(chunk))
|
|
|
|
|
|
|
|
return sorted(set(ints))
|
|
|
|
|
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
def extract_txp2(
|
|
|
|
fname: str,
|
|
|
|
output_dir: str,
|
|
|
|
*,
|
|
|
|
split_textures: bool=False,
|
|
|
|
generate_mapping_overlays: bool=False,
|
|
|
|
write_mappings: bool=False,
|
|
|
|
write_raw: bool=False,
|
|
|
|
write_binaries: bool=False,
|
|
|
|
pretend: bool=False,
|
|
|
|
verbose: bool=False,
|
|
|
|
) -> int:
|
|
|
|
if split_textures:
|
|
|
|
if write_raw:
|
|
|
|
raise Exception("Cannot write raw textures when splitting sprites!")
|
|
|
|
if generate_mapping_overlays:
|
|
|
|
raise Exception("Cannot generate mapping overlays when splitting sprites!")
|
|
|
|
|
|
|
|
with open(fname, "rb") as bfp:
|
|
|
|
afpfile = TXP2File(bfp.read(), verbose=verbose)
|
|
|
|
|
|
|
|
# Actually place the files down.
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
|
|
|
|
if not split_textures:
|
|
|
|
for texture in afpfile.textures:
|
|
|
|
filename = os.path.join(output_dir, texture.name)
|
|
|
|
|
|
|
|
if texture.img:
|
|
|
|
if 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 write_raw:
|
|
|
|
if 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 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"""
|
|
|
|
<info>
|
|
|
|
<width>{texture.width}</width>
|
|
|
|
<height>{texture.height}</height>
|
|
|
|
<type>{hex(texture.fmt)}</type>
|
|
|
|
<raw>{filename}.raw</raw>
|
|
|
|
</info>
|
|
|
|
""").strip())
|
|
|
|
|
|
|
|
if write_mappings:
|
|
|
|
if not 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(output_dir, name)
|
|
|
|
|
|
|
|
if 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"""
|
|
|
|
<info>
|
|
|
|
<left>{region.left}</left>
|
|
|
|
<top>{region.top}</top>
|
|
|
|
<right>{region.right}</right>
|
|
|
|
<bottom>{region.bottom}</bottom>
|
|
|
|
<texture>{texturename}</texture>
|
|
|
|
</info>
|
|
|
|
""").strip())
|
|
|
|
|
|
|
|
if afpfile.fontdata is not None:
|
|
|
|
filename = os.path.join(output_dir, "fontinfo.xml")
|
|
|
|
|
|
|
|
if 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 write_binaries:
|
|
|
|
for i, name in enumerate(afpfile.swfmap.entries):
|
|
|
|
swf = afpfile.swfdata[i]
|
|
|
|
filename = os.path.join(output_dir, name)
|
|
|
|
|
|
|
|
if pretend:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Would write {filename}.afp animation data...")
|
|
|
|
print(f"Would write {filename}.bsi animation descramble data...")
|
2021-05-29 05:41:03 +02:00
|
|
|
else:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Writing {filename}.afp animation data...")
|
2021-05-29 05:41:03 +02:00
|
|
|
with open(f"{filename}.afp", "wb") as bfp:
|
|
|
|
bfp.write(swf.data)
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Writing {filename}.bsi animation descramble data...")
|
2021-05-29 05:41:03 +02:00
|
|
|
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(output_dir, f"{name}.geo")
|
|
|
|
|
|
|
|
if 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 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(output_dir, name) + "_overlay.png"
|
|
|
|
if 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 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(output_dir, filename)
|
|
|
|
|
|
|
|
if 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 write_bytecode:
|
|
|
|
for swf in afpfile.swfdata:
|
|
|
|
write_bytecode(swf, output_dir, verbose=verbose)
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def update_txp2(fname: str, update_dir: str, *, pretend: bool=False, verbose: bool=False) -> int:
|
|
|
|
# First, parse the file out
|
|
|
|
with open(fname, "rb") as bfp:
|
|
|
|
afpfile = TXP2File(bfp.read(), verbose=verbose)
|
|
|
|
|
|
|
|
# Now, find any PNG files that match texture names.
|
|
|
|
for texture in afpfile.textures:
|
|
|
|
filename = os.path.join(update_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(update_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 pretend:
|
|
|
|
print(f"Would write {fname}...")
|
|
|
|
afpfile.unparse()
|
|
|
|
else:
|
|
|
|
print(f"Writing {fname}...")
|
|
|
|
data = afpfile.unparse()
|
|
|
|
with open(fname, "wb") as bfp:
|
|
|
|
bfp.write(data)
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def print_txp2(fname: str, *, decompile_bytecode: bool=False, verbose: bool=False) -> int:
|
|
|
|
# First, parse the file out
|
|
|
|
with open(fname, "rb") as bfp:
|
|
|
|
afpfile = TXP2File(bfp.read(), verbose=verbose)
|
|
|
|
|
|
|
|
# Now, print it
|
|
|
|
print(json.dumps(afpfile.as_dict(decompile_bytecode=decompile_bytecode, verbose=verbose), sort_keys=True, indent=4))
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def parse_afp(afp: str, bsi: str, *, decompile_bytecode: bool=False, verbose: bool=False) -> int:
|
|
|
|
# First, load the AFP and BSI files
|
|
|
|
with open(afp, "rb") as bafp:
|
|
|
|
with open(bsi, "rb") as bbsi:
|
|
|
|
swf = SWF("<unnamed>", bafp.read(), bbsi.read())
|
|
|
|
|
|
|
|
# Now, print it
|
|
|
|
swf.parse(verbose=verbose)
|
|
|
|
print(json.dumps(swf.as_dict(decompile_bytecode=decompile_bytecode, verbose=verbose), sort_keys=True, indent=4))
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def decompile_afp(afp: str, bsi: str, output_dir: str, *, decompile_bytecode: bool=False, verbose: bool=False) -> int:
|
|
|
|
# First, load the AFP and BSI files
|
|
|
|
with open(afp, "rb") as bafp:
|
|
|
|
with open(bsi, "rb") as bbsi:
|
|
|
|
swf = SWF("<unnamed>", bafp.read(), bbsi.read())
|
|
|
|
|
|
|
|
# Now, decompile it
|
|
|
|
swf.parse(verbose=verbose)
|
|
|
|
write_bytecode(swf, output_dir, verbose=verbose)
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def parse_geo(geo: str, *, verbose: bool=False) -> int:
|
|
|
|
# First, load the AFP and BSI files
|
|
|
|
with open(geo, "rb") as bfp:
|
|
|
|
shape = Shape("<unnamed>", bfp.read())
|
|
|
|
|
|
|
|
# Now, print it
|
|
|
|
shape.parse()
|
|
|
|
if verbose:
|
|
|
|
print(shape, file=sys.stderr)
|
|
|
|
print(json.dumps(shape.as_dict(), sort_keys=True, indent=4))
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
2021-05-29 05:41:25 +02:00
|
|
|
def load_containers(renderer: AFPRenderer, containers: List[str], *, need_extras: bool, verbose: bool) -> None:
|
2021-05-29 05:41:03 +02:00
|
|
|
# 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.
|
|
|
|
for container in containers:
|
|
|
|
with open(container, "rb") as bfp:
|
|
|
|
data = bfp.read()
|
|
|
|
|
|
|
|
afpfile = None
|
|
|
|
try:
|
|
|
|
afpfile = TXP2File(data, verbose=verbose)
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
|
|
|
|
if afpfile is not None:
|
|
|
|
if verbose:
|
|
|
|
print(f"Loading files out of TXP2 container {container}...", file=sys.stderr)
|
|
|
|
|
2021-05-29 05:41:25 +02:00
|
|
|
if need_extras:
|
|
|
|
# First, load GE2D structures into the renderer.
|
|
|
|
for i, name in enumerate(afpfile.shapemap.entries):
|
|
|
|
shape = afpfile.shapes[i]
|
|
|
|
renderer.add_shape(name, shape)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
2021-05-29 05:41:25 +02:00
|
|
|
if verbose:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Added {name} to animation shape library.", file=sys.stderr)
|
2021-05-29 05:41:25 +02:00
|
|
|
|
|
|
|
# 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)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
2021-05-29 05:41:25 +02:00
|
|
|
if verbose:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Added {name} to animation texture library.", file=sys.stderr)
|
2021-05-29 05:41:03 +02:00
|
|
|
else:
|
2021-05-29 05:41:25 +02:00
|
|
|
print(f"Cannot load {name} from {texturename} because it is not a supported format!")
|
2021-05-29 05:41:03 +02:00
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
# Finally, load the animation data itself into the renderer.
|
2021-05-29 05:41:03 +02:00
|
|
|
for i, name in enumerate(afpfile.swfmap.entries):
|
|
|
|
swf = afpfile.swfdata[i]
|
|
|
|
renderer.add_swf(name, swf)
|
|
|
|
|
|
|
|
if verbose:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Added {name} to animation library.", file=sys.stderr)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
ifsfile = None
|
|
|
|
try:
|
|
|
|
ifsfile = IFS(data, decode_textures=True)
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
|
|
|
|
if ifsfile is not None:
|
|
|
|
if 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}"):
|
2021-05-29 05:41:25 +02:00
|
|
|
if not need_extras:
|
|
|
|
continue
|
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
# 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 verbose:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Added {shapename} to animation shape library.", file=sys.stderr)
|
2021-05-29 05:41:03 +02:00
|
|
|
elif fname.startswith(f"tex{os.sep}") and fname.endswith(".png"):
|
2021-05-29 05:41:25 +02:00
|
|
|
if not need_extras:
|
|
|
|
continue
|
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
# 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 verbose:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Added {texname} to animation texture library.", file=sys.stderr)
|
2021-05-29 05:41:03 +02:00
|
|
|
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 verbose:
|
2021-08-12 17:57:37 +02:00
|
|
|
print(f"Added {afpname} to animation library.", file=sys.stderr)
|
2021-05-29 05:41:03 +02:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
2021-05-29 05:41:25 +02:00
|
|
|
def list_paths(containers: List[str], *, include_frames: bool=False, include_size: bool=False, verbose: bool=False) -> int:
|
2021-05-29 05:41:03 +02:00
|
|
|
renderer = AFPRenderer()
|
2021-05-29 05:41:25 +02:00
|
|
|
load_containers(renderer, containers, need_extras=False, verbose=verbose)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
|
|
|
for path in renderer.list_paths(verbose=verbose):
|
2021-05-29 05:41:25 +02:00
|
|
|
display = path
|
|
|
|
if include_size:
|
|
|
|
location = renderer.compute_path_size(path)
|
|
|
|
display = f"{display} - {int(location.width)}x{int(location.height)}"
|
|
|
|
if include_frames:
|
|
|
|
frames = renderer.compute_path_frames(path)
|
|
|
|
display = f"{display} - {frames} frames"
|
|
|
|
print(display)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
2021-08-12 17:57:02 +02:00
|
|
|
BackgroundT = TypeVar("BackgroundT")
|
|
|
|
|
|
|
|
|
|
|
|
def adjust_background_loop(
|
|
|
|
background: List[BackgroundT],
|
|
|
|
background_loop_start: Optional[int] = None,
|
|
|
|
background_loop_end: Optional[int] = None,
|
|
|
|
background_loop_offset: Optional[int] = None,
|
|
|
|
) -> List[BackgroundT]:
|
|
|
|
# Make sure background frames are 1-indexed here as well.
|
|
|
|
if background_loop_start is None:
|
|
|
|
background_loop_start = 0
|
|
|
|
else:
|
|
|
|
background_loop_start -= 1
|
|
|
|
|
|
|
|
if background_loop_offset is None:
|
|
|
|
background_loop_offset = 0
|
|
|
|
else:
|
|
|
|
background_loop_offset -= (background_loop_start + 1)
|
|
|
|
|
|
|
|
# Don't one-index the end because we want it to be inclusive.
|
|
|
|
if background_loop_end is None:
|
|
|
|
background_loop_end = len(background)
|
|
|
|
|
|
|
|
if background_loop_start >= background_loop_end:
|
|
|
|
raise Exception("Cannot start background loop after the end of the background loop!")
|
|
|
|
if background_loop_start < 0 or background_loop_end < 0:
|
|
|
|
raise Exception("Cannot start or end background loop on a negative frame!")
|
|
|
|
if background_loop_start >= len(background) or background_loop_end > len(background):
|
|
|
|
raise Exception("Cannot start or end background loop larger than the number of background animation frames!")
|
|
|
|
|
|
|
|
background = background[background_loop_start:background_loop_end]
|
|
|
|
|
|
|
|
if background_loop_offset < 0 or background_loop_offset >= len(background):
|
|
|
|
raise Exception("Cannot start first iteration of background loop outside the loop bounds!")
|
|
|
|
|
|
|
|
return background[background_loop_offset:] + background[:background_loop_offset]
|
|
|
|
|
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
def render_path(
|
|
|
|
containers: List[str],
|
|
|
|
path: str,
|
|
|
|
output: str,
|
|
|
|
*,
|
|
|
|
disable_threads: bool = False,
|
2021-05-31 20:10:25 +02:00
|
|
|
enable_anti_aliasing: bool = False,
|
2021-05-29 05:41:03 +02:00
|
|
|
background_color: Optional[str] = None,
|
|
|
|
background_image: Optional[str] = None,
|
2021-08-11 23:40:38 +02:00
|
|
|
background_loop_start: Optional[int] = None,
|
|
|
|
background_loop_end: Optional[int] = None,
|
2021-08-12 17:57:02 +02:00
|
|
|
background_loop_offset: Optional[int] = None,
|
2021-06-13 23:38:41 +02:00
|
|
|
force_width: Optional[int] = None,
|
|
|
|
force_height: Optional[int] = None,
|
2021-05-29 05:41:03 +02:00
|
|
|
force_aspect_ratio: Optional[str] = None,
|
|
|
|
scale_width: float = 1.0,
|
|
|
|
scale_height: float = 1.0,
|
|
|
|
only_depths: Optional[str] = None,
|
|
|
|
only_frames: Optional[str] = None,
|
|
|
|
verbose: bool = False,
|
2021-08-03 19:03:59 +02:00
|
|
|
show_progress: bool = False,
|
2021-05-29 05:41:03 +02:00
|
|
|
) -> int:
|
2021-08-03 19:03:59 +02:00
|
|
|
if show_progress:
|
|
|
|
print("Loading textures, shapes and animation instructions...")
|
|
|
|
|
2021-05-31 20:10:25 +02:00
|
|
|
renderer = AFPRenderer(single_threaded=disable_threads, enable_aa=enable_anti_aliasing)
|
2021-05-29 05:41:25 +02:00
|
|
|
load_containers(renderer, containers, need_extras=True, verbose=verbose)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
2021-08-03 19:03:59 +02:00
|
|
|
if show_progress:
|
|
|
|
print("Calculating render parameters...")
|
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
# Verify the correct params.
|
|
|
|
if output.lower().endswith(".gif"):
|
|
|
|
fmt = "GIF"
|
|
|
|
elif output.lower().endswith(".webp"):
|
|
|
|
fmt = "WEBP"
|
|
|
|
elif output.lower().endswith(".png"):
|
|
|
|
fmt = "PNG"
|
|
|
|
else:
|
|
|
|
raise Exception("Unrecognized file extension for output!")
|
|
|
|
|
|
|
|
# Allow overriding background color.
|
|
|
|
if background_color:
|
|
|
|
colorvals = 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
|
|
|
|
|
2021-08-11 23:40:01 +02:00
|
|
|
# Allow inserting a background image, series of images or animation.
|
2021-05-29 05:41:03 +02:00
|
|
|
if background_image:
|
2021-08-11 23:40:01 +02:00
|
|
|
background_image = os.path.abspath(background_image)
|
|
|
|
background: List[Image.Image] = []
|
|
|
|
|
|
|
|
if os.path.isfile(background_image):
|
2021-08-15 02:02:42 +02:00
|
|
|
# This is a direct reference, open it.
|
|
|
|
with open(background_image, "rb") as bfp:
|
|
|
|
# Work around the fact that PIL does not read the image until first use,
|
|
|
|
# meaning a long background image sequence can blow past max open files.
|
|
|
|
bgimg = Image.open(io.BytesIO(bfp.read()))
|
2021-08-11 23:40:01 +02:00
|
|
|
frames = getattr(bgimg, "n_frames", 1)
|
|
|
|
|
|
|
|
if frames == 1:
|
|
|
|
background.append(bgimg)
|
|
|
|
elif frames > 1:
|
|
|
|
for frame in range(frames):
|
|
|
|
bgimg.seek(frame)
|
|
|
|
background.append(bgimg.copy())
|
|
|
|
else:
|
|
|
|
raise Exception("Invalid image specified as background!")
|
|
|
|
else:
|
|
|
|
# This is probably a reference to a list of images.
|
|
|
|
dirof, fileof = os.path.split(background_image)
|
|
|
|
startof, endof = os.path.splitext(fileof)
|
|
|
|
if len(startof) == 0 or len(endof) == 0:
|
|
|
|
raise Exception("Invalid image specified as background!")
|
|
|
|
startof = startof + '-'
|
|
|
|
|
|
|
|
# Gather up the sequence of files so we can make frames out of them.
|
|
|
|
seqdict: Dict[int, str] = {}
|
|
|
|
for filename in os.listdir(dirof):
|
|
|
|
if filename.startswith(startof) and filename.endswith(endof):
|
|
|
|
seqno = filename[len(startof):(-len(endof))]
|
|
|
|
if seqno.isdigit():
|
|
|
|
seqint = int(seqno)
|
|
|
|
if seqint in seqdict:
|
|
|
|
raise Exception(f"{filename} specifies the same background frame number as {seqdict[seqint]}!")
|
|
|
|
seqdict[seqint] = filename
|
|
|
|
|
|
|
|
# Now, order the sequence by the integer of the sequence number so we can load the images.
|
|
|
|
seqtuple: List[Tuple[int, str]] = sorted(
|
|
|
|
[(s, p) for (s, p) in seqdict.items()],
|
|
|
|
key=lambda e: e[0],
|
|
|
|
)
|
|
|
|
|
|
|
|
# Finally, get the filenames from this sequence.
|
|
|
|
filenames: List[str] = [os.path.join(dirof, filename) for (_, filename) in seqtuple]
|
|
|
|
|
|
|
|
# Now that we have the list, lets load the images!
|
|
|
|
for filename in filenames:
|
2021-08-15 02:02:42 +02:00
|
|
|
with open(filename, "rb") as bfp:
|
|
|
|
# Work around the fact that PIL does not read the image until first use,
|
|
|
|
# meaning a long background image sequence can blow past max open files.
|
|
|
|
bgimg = Image.open(io.BytesIO(bfp.read()))
|
2021-08-11 23:40:01 +02:00
|
|
|
frames = getattr(bgimg, "n_frames", 1)
|
|
|
|
|
|
|
|
if frames == 1:
|
|
|
|
background.append(bgimg)
|
|
|
|
elif frames > 1:
|
|
|
|
for frame in range(frames):
|
|
|
|
bgimg.seek(frame)
|
|
|
|
background.append(bgimg.copy())
|
|
|
|
else:
|
|
|
|
raise Exception("Invalid image specified as background!")
|
2021-08-11 23:40:38 +02:00
|
|
|
|
2021-08-12 17:57:02 +02:00
|
|
|
background = adjust_background_loop(background, background_loop_start, background_loop_end, background_loop_offset)
|
2021-05-29 05:41:03 +02:00
|
|
|
else:
|
|
|
|
background = None
|
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
# Calculate the size of the animation so we can apply scaling options.
|
2021-05-29 05:41:03 +02:00
|
|
|
swf_location = renderer.compute_path_location(path)
|
2021-06-13 23:38:41 +02:00
|
|
|
requested_width = force_width if force_width is not None else swf_location.width
|
|
|
|
requested_height = force_height if force_height is not None else swf_location.height
|
2021-05-29 05:41:03 +02:00
|
|
|
|
|
|
|
# Allow overriding the aspect ratio.
|
|
|
|
if force_aspect_ratio:
|
|
|
|
ratio = force_aspect_ratio.split(":")
|
|
|
|
if len(ratio) != 2:
|
|
|
|
raise Exception("Invalid aspect ratio, specify a ratio such as 16:9 or 4:3!")
|
|
|
|
|
|
|
|
rx, ry = [float(r.strip()) for r in ratio]
|
|
|
|
if rx <= 0 or ry <= 0:
|
|
|
|
raise Exception("Ratio must only include positive numbers!")
|
|
|
|
|
|
|
|
actual_ratio = rx / ry
|
|
|
|
swf_ratio = swf_location.width / swf_location.height
|
|
|
|
|
|
|
|
if abs(swf_ratio - actual_ratio) > 0.0001:
|
|
|
|
new_width = actual_ratio * swf_location.height
|
|
|
|
new_height = swf_location.width / actual_ratio
|
|
|
|
|
|
|
|
if new_width < swf_location.width and new_height < swf_location.height:
|
|
|
|
raise Exception("Impossible aspect ratio!")
|
|
|
|
if new_width > swf_location.width and new_height > swf_location.height:
|
|
|
|
raise Exception("Impossible aspect ratio!")
|
|
|
|
|
|
|
|
# We know that one is larger and one is smaller, pick the larger.
|
|
|
|
# This way we always stretch instead of shrinking.
|
|
|
|
if new_width > swf_location.width:
|
|
|
|
requested_width = new_width
|
|
|
|
else:
|
|
|
|
requested_height = new_height
|
|
|
|
|
2021-06-13 23:38:41 +02:00
|
|
|
# Finally, apply requested final scaling.
|
2021-05-29 05:41:03 +02:00
|
|
|
requested_width *= scale_width
|
|
|
|
requested_height *= scale_height
|
|
|
|
|
|
|
|
# Calculate the overall view matrix based on the requested width/height.
|
2021-07-06 23:58:32 +02:00
|
|
|
transform = Matrix.affine(
|
2021-05-29 05:41:03 +02:00
|
|
|
a=requested_width / swf_location.width,
|
|
|
|
b=0.0,
|
|
|
|
c=0.0,
|
|
|
|
d=requested_height / swf_location.height,
|
|
|
|
tx=0.0,
|
|
|
|
ty=0.0,
|
|
|
|
)
|
|
|
|
|
|
|
|
# Support rendering only certain depth planes.
|
|
|
|
if only_depths is not None:
|
|
|
|
requested_depths = parse_intlist(only_depths)
|
|
|
|
else:
|
|
|
|
requested_depths = None
|
|
|
|
|
|
|
|
# Support rendering only certain frames.
|
|
|
|
if only_frames is not None:
|
|
|
|
requested_frames = parse_intlist(only_frames)
|
|
|
|
else:
|
|
|
|
requested_frames = None
|
|
|
|
|
|
|
|
if fmt in ["GIF", "WEBP"]:
|
|
|
|
# Write all the frames out in one file.
|
|
|
|
duration = renderer.compute_path_frame_duration(path)
|
2021-08-03 19:03:59 +02:00
|
|
|
frames = renderer.compute_path_frames(path)
|
|
|
|
images: List[Image] = []
|
|
|
|
for i, img in enumerate(
|
2021-05-29 05:41:03 +02:00
|
|
|
renderer.render_path(
|
|
|
|
path,
|
|
|
|
verbose=verbose,
|
|
|
|
background_color=color,
|
|
|
|
background_image=background,
|
|
|
|
only_depths=requested_depths,
|
|
|
|
only_frames=requested_frames,
|
|
|
|
movie_transform=transform,
|
|
|
|
)
|
2021-08-03 19:03:59 +02:00
|
|
|
):
|
|
|
|
if show_progress:
|
2021-08-09 21:08:41 +02:00
|
|
|
frameno = requested_frames[i] if requested_frames is not None else (i + 1)
|
|
|
|
print(f"Rendered animation frame {frameno}/{frames}.")
|
2021-08-03 19:03:59 +02:00
|
|
|
images.append(img)
|
|
|
|
|
2021-05-31 20:13:04 +02:00
|
|
|
if len(images) > 0:
|
2021-06-12 20:50:17 +02:00
|
|
|
try:
|
|
|
|
dirof = os.path.dirname(os.path.abspath(output))
|
|
|
|
os.makedirs(dirof, exist_ok=True)
|
|
|
|
except FileNotFoundError:
|
|
|
|
# Apparently on OSX this is possible?
|
|
|
|
pass
|
2021-06-12 19:16:45 +02:00
|
|
|
|
2021-05-31 20:13:04 +02:00
|
|
|
with open(output, "wb") as bfp:
|
|
|
|
images[0].save(bfp, format=fmt, save_all=True, append_images=images[1:], duration=duration, optimize=True)
|
2021-05-29 05:41:03 +02:00
|
|
|
|
2021-05-31 20:13:04 +02:00
|
|
|
print(f"Wrote animation to {output}")
|
2021-05-29 05:41:03 +02:00
|
|
|
else:
|
|
|
|
# Write all the frames out in individual_files.
|
|
|
|
filename = output[:-4]
|
|
|
|
ext = output[-4:]
|
|
|
|
|
|
|
|
# Figure out padding for the images.
|
|
|
|
frames = renderer.compute_path_frames(path)
|
|
|
|
if frames > 0:
|
|
|
|
digits = f"0{int(math.log10(frames)) + 1}"
|
|
|
|
|
|
|
|
for i, img in enumerate(
|
|
|
|
renderer.render_path(
|
|
|
|
path,
|
|
|
|
verbose=verbose,
|
|
|
|
background_color=color,
|
|
|
|
background_image=background,
|
|
|
|
only_depths=requested_depths,
|
|
|
|
only_frames=requested_frames,
|
|
|
|
movie_transform=transform,
|
|
|
|
)
|
|
|
|
):
|
2021-08-09 21:08:41 +02:00
|
|
|
frameno = requested_frames[i] if requested_frames is not None else (i + 1)
|
|
|
|
fullname = f"{filename}-{frameno:{digits}}{ext}"
|
2021-05-29 05:41:03 +02:00
|
|
|
|
2021-06-12 20:50:17 +02:00
|
|
|
try:
|
|
|
|
dirof = os.path.dirname(os.path.abspath(fullname))
|
|
|
|
os.makedirs(dirof, exist_ok=True)
|
|
|
|
except FileNotFoundError:
|
|
|
|
# Apparently on OSX this is possible?
|
|
|
|
pass
|
2021-06-12 19:16:45 +02:00
|
|
|
|
2021-05-29 05:41:03 +02:00
|
|
|
with open(fullname, "wb") as bfp:
|
|
|
|
img.save(bfp, format=fmt)
|
|
|
|
|
|
|
|
print(f"Wrote animation frame to {fullname}")
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
2020-11-06 03:08:21 +01:00
|
|
|
def main() -> int:
|
2021-08-12 17:57:37 +02:00
|
|
|
parser = argparse.ArgumentParser(description="Konami AFP graphic file unpacker/repacker.")
|
2020-11-11 04:39:51 +01:00
|
|
|
subparsers = parser.add_subparsers(help='Action to take', dest='action')
|
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
extract_parser = subparsers.add_parser(
|
|
|
|
'extract',
|
|
|
|
help='Extract relevant file data and textures from a TXP2 container',
|
|
|
|
description="Extract textures, sprites, decompiled bytecode, AFP, BSI and GEO files from a TXP2 container.",
|
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
extract_parser.add_argument(
|
2020-11-06 03:08:21 +01:00
|
|
|
"file",
|
|
|
|
metavar="FILE",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="The TXP2 container to extract",
|
2020-11-06 03:08:21 +01:00
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
extract_parser.add_argument(
|
2020-11-06 03:08:21 +01:00
|
|
|
"dir",
|
|
|
|
metavar="DIR",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="The directory to extract all contents to",
|
2020-11-06 03:08:21 +01:00
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
extract_parser.add_argument(
|
2020-11-06 03:08:21 +01:00
|
|
|
"-p",
|
|
|
|
"--pretend",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Pretend to extract instead of extracting, printing what would have been extracted",
|
2020-11-06 03:08:21 +01:00
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
extract_parser.add_argument(
|
2020-11-06 03:08:21 +01:00
|
|
|
"-v",
|
|
|
|
"--verbose",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Display verbose debugging output",
|
2020-11-06 03:08:21 +01:00
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
extract_parser.add_argument(
|
2020-11-06 19:40:41 +01:00
|
|
|
"-r",
|
|
|
|
"--write-raw",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Always write raw texture data instead of only writing raw texture data for unrecognized texture formats",
|
2020-11-06 19:40:41 +01:00
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
extract_parser.add_argument(
|
|
|
|
"-m",
|
2020-11-06 20:57:13 +01:00
|
|
|
"--write-mappings",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Write mapping files to disk as XML files",
|
2020-11-11 04:39:51 +01:00
|
|
|
)
|
2020-11-23 21:56:05 +01:00
|
|
|
extract_parser.add_argument(
|
|
|
|
"-g",
|
|
|
|
"--generate-mapping-overlays",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Generate overlay images showing mappings between textures and individual sprites",
|
2020-11-23 21:56:05 +01:00
|
|
|
)
|
2020-11-29 01:47:25 +01:00
|
|
|
extract_parser.add_argument(
|
|
|
|
"-s",
|
|
|
|
"--split-textures",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Split textures into individual sprite image files",
|
2020-11-29 01:47:25 +01:00
|
|
|
)
|
2021-04-11 22:45:40 +02:00
|
|
|
extract_parser.add_argument(
|
|
|
|
"-b",
|
|
|
|
"--write-binaries",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Write raw AFP/BSI/GEO files to disk",
|
2021-04-11 22:45:40 +02:00
|
|
|
)
|
2021-05-10 02:12:54 +02:00
|
|
|
extract_parser.add_argument(
|
|
|
|
"-y",
|
|
|
|
"--write-bytecode",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Write decompiled bytecode files found in AFP files to disk",
|
2021-05-10 02:12:54 +02:00
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
update_parser = subparsers.add_parser(
|
|
|
|
'update',
|
|
|
|
help='Update relevant textures in a TXP2 container from a directory',
|
|
|
|
description="Update textures and sprites in a TXP2 container based on images in a directory.",
|
|
|
|
)
|
2020-11-11 04:39:51 +01:00
|
|
|
update_parser.add_argument(
|
|
|
|
"file",
|
|
|
|
metavar="FILE",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="The TXP2 container to update",
|
2020-11-11 04:39:51 +01:00
|
|
|
)
|
|
|
|
update_parser.add_argument(
|
|
|
|
"dir",
|
|
|
|
metavar="DIR",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Directory to update all contents from",
|
2020-11-11 04:39:51 +01:00
|
|
|
)
|
|
|
|
update_parser.add_argument(
|
|
|
|
"-p",
|
|
|
|
"--pretend",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Pretend to update instead of updating, printing what would have been updated",
|
2020-11-11 04:39:51 +01:00
|
|
|
)
|
|
|
|
update_parser.add_argument(
|
|
|
|
"-v",
|
|
|
|
"--verbose",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Display verbose debugging output",
|
2020-11-06 20:57:13 +01:00
|
|
|
)
|
2020-11-27 18:47:48 +01:00
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
print_parser = subparsers.add_parser(
|
|
|
|
'print',
|
|
|
|
help='Print a TXP2 container\'s contents as a JSON dictionary',
|
|
|
|
description='Print a TXP2 container\'s contents as a JSON dictionary.',
|
|
|
|
)
|
2020-11-27 18:47:48 +01:00
|
|
|
print_parser.add_argument(
|
|
|
|
"file",
|
|
|
|
metavar="FILE",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="The TXP2 container to print",
|
2020-11-27 18:47:48 +01:00
|
|
|
)
|
2021-04-24 19:59:36 +02:00
|
|
|
print_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"-v",
|
|
|
|
"--verbose",
|
2021-04-24 19:59:36 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Display verbose debugging output",
|
2021-04-24 19:59:36 +02:00
|
|
|
)
|
2021-03-30 06:50:05 +02:00
|
|
|
print_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"-d",
|
|
|
|
"--decompile-bytecode",
|
2021-03-30 06:50:05 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Attempt to decompile and print pseudocode instead of printing the raw bytecode.",
|
2021-03-30 06:50:05 +02:00
|
|
|
)
|
2020-11-27 18:47:48 +01:00
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
parseafp_parser = subparsers.add_parser(
|
|
|
|
'parseafp',
|
|
|
|
help='Parse a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container',
|
|
|
|
description='Parse a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container.',
|
|
|
|
)
|
2021-04-03 07:30:19 +02:00
|
|
|
parseafp_parser.add_argument(
|
2021-04-03 07:27:09 +02:00
|
|
|
"afp",
|
|
|
|
metavar="AFPFILE",
|
|
|
|
help="The AFP file to parse",
|
|
|
|
)
|
2021-04-03 07:30:19 +02:00
|
|
|
parseafp_parser.add_argument(
|
2021-04-03 07:27:09 +02:00
|
|
|
"bsi",
|
|
|
|
metavar="BSIFILE",
|
|
|
|
help="The BSI file to parse",
|
|
|
|
)
|
2021-04-24 19:59:36 +02:00
|
|
|
parseafp_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"-v",
|
|
|
|
"--verbose",
|
2021-04-24 19:59:36 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Display verbose debugging output",
|
2021-04-24 19:59:36 +02:00
|
|
|
)
|
2021-04-03 07:30:19 +02:00
|
|
|
parseafp_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"-d",
|
|
|
|
"--decompile-bytecode",
|
2021-04-03 07:30:19 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Attempt to decompile and print pseudocode instead of printing the raw bytecode.",
|
2021-04-03 07:30:19 +02:00
|
|
|
)
|
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
decompile_parser = subparsers.add_parser(
|
|
|
|
'decompile',
|
|
|
|
help='Decompile bytecode in a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container',
|
|
|
|
description='Decompile bytecode in a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container.',
|
|
|
|
)
|
2021-05-10 02:12:54 +02:00
|
|
|
decompile_parser.add_argument(
|
|
|
|
"afp",
|
|
|
|
metavar="AFPFILE",
|
|
|
|
help="The AFP file to parse",
|
|
|
|
)
|
|
|
|
decompile_parser.add_argument(
|
|
|
|
"bsi",
|
|
|
|
metavar="BSIFILE",
|
|
|
|
help="The BSI file to parse",
|
|
|
|
)
|
2021-08-12 17:57:37 +02:00
|
|
|
decompile_parser.add_argument(
|
|
|
|
"-v",
|
|
|
|
"--verbose",
|
|
|
|
action="store_true",
|
|
|
|
help="Display verbose debugging output",
|
|
|
|
)
|
2021-05-10 02:12:54 +02:00
|
|
|
decompile_parser.add_argument(
|
|
|
|
"-d",
|
|
|
|
"--directory",
|
|
|
|
metavar="DIR",
|
|
|
|
default='.',
|
|
|
|
type=str,
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Directory to write decompiled pseudocode files. Defaults to current directory.",
|
2021-05-10 02:12:54 +02:00
|
|
|
)
|
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
parsegeo_parser = subparsers.add_parser(
|
|
|
|
'parsegeo',
|
|
|
|
help='Parse a raw GEO file previously extracted from an IFS or TXP2 container',
|
|
|
|
description='Parse a raw GEO file previously extracted from an IFS or TXP2 container.',
|
|
|
|
)
|
2021-04-03 07:30:19 +02:00
|
|
|
parsegeo_parser.add_argument(
|
|
|
|
"geo",
|
|
|
|
metavar="GEOFILE",
|
|
|
|
help="The GEO file to parse",
|
|
|
|
)
|
|
|
|
parsegeo_parser.add_argument(
|
2021-04-03 07:27:09 +02:00
|
|
|
"-v",
|
|
|
|
"--verbose",
|
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Display verbose debugging output",
|
2021-04-03 07:27:09 +02:00
|
|
|
)
|
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
render_parser = subparsers.add_parser(
|
|
|
|
'render',
|
|
|
|
help='Render a particular animation out of a collection of TXP2 or IFS containers',
|
|
|
|
description='Render a particular animation out of a collection of TXP2 or IFS containers.',
|
|
|
|
)
|
2021-04-16 01:18:33 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"container",
|
|
|
|
metavar="CONTAINER",
|
|
|
|
type=str,
|
|
|
|
nargs='+',
|
2021-08-12 17:57:37 +02:00
|
|
|
help="A container to use for loading animation data. Can be either a TXP2 or IFS container.",
|
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"-v",
|
|
|
|
"--verbose",
|
|
|
|
action="store_true",
|
|
|
|
help="Display verbose debugging output",
|
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"-s",
|
|
|
|
"--show-progress",
|
|
|
|
action="store_true",
|
|
|
|
help="Display per-frame rendering progress",
|
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--disable-threads",
|
|
|
|
action="store_true",
|
|
|
|
help="Disable multi-threaded rendering. The animation will be rendered on a single core and threads will not be spawned.",
|
2021-04-16 01:18:33 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--path",
|
|
|
|
metavar="PATH",
|
|
|
|
type=str,
|
|
|
|
required=True,
|
2021-08-12 17:57:37 +02:00
|
|
|
help='The path of the animation to render. Use the "list" command to discover paths in a container.',
|
2021-04-16 01:18:33 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--output",
|
|
|
|
metavar="IMAGE",
|
|
|
|
type=str,
|
|
|
|
default="out.gif",
|
2021-08-11 23:40:01 +02:00
|
|
|
help=(
|
|
|
|
'The output file (ending either in .gif, .webp or .png) where the render should be saved. If .png is chosen then the '
|
2021-08-12 17:57:37 +02:00
|
|
|
'output will be a series of png files for each rendered frame. If .gif or .webp is chosen the output will be an '
|
2021-08-12 20:59:25 +02:00
|
|
|
'animated image. Note that the .gif file format has several severe limitations which result in sub-optimal animations '
|
|
|
|
'so it is recommended to use .webp or .png instead.'
|
2021-08-11 23:40:01 +02:00
|
|
|
),
|
2021-04-16 01:18:33 +02:00
|
|
|
)
|
2021-04-21 03:06:48 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--background-color",
|
|
|
|
type=str,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Set the background color of the animation as a comma-separated RGB or RGBA color (such as 255,0,0 for red or "
|
|
|
|
"0,255,0,128 for translucent green), overriding any default in the animation."
|
|
|
|
),
|
2021-05-16 17:15:06 +02:00
|
|
|
)
|
2021-05-24 01:37:05 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--background-image",
|
|
|
|
type=str,
|
|
|
|
default=None,
|
2021-08-11 23:40:01 +02:00
|
|
|
help=(
|
2021-08-12 17:57:37 +02:00
|
|
|
"Set a background image or animation to be placed behind the animation but in front of the background color. "
|
|
|
|
"Note that the background will be stretched to fit the animation. If a .png is specified and multiple rendered "
|
|
|
|
"frames are present it will use that series as an animation. If a static image is specified and mulitple frames "
|
|
|
|
"are not present it will use that as a static background. If an animated image is specified it will use that "
|
|
|
|
"as an animated background."
|
2021-08-11 23:40:01 +02:00
|
|
|
),
|
2021-05-24 01:37:05 +02:00
|
|
|
)
|
2021-08-11 23:40:38 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--background-loop-start",
|
|
|
|
type=int,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"The starting frame of the background animation loop. Specify this to loop to a background animation frame other "
|
|
|
|
"than the first. For example, if your background animation has 10 frames and you specify a loop start of 6, the "
|
|
|
|
"resulting background animation loop will contain frames 6, 7, 8, 9 and 10 played in that order."
|
|
|
|
),
|
2021-08-11 23:40:38 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--background-loop-end",
|
|
|
|
type=int,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"The ending frame of the background animation loop. Specify this to loop from a background animation frame other "
|
|
|
|
"than the last. For example, if your background animation has 10 frames and you specify a loop end of 8, the "
|
|
|
|
"resulting background animation loop will contain frames 1, 2, 3, 4, 5, 6, 7 and 8 played in that order."
|
|
|
|
),
|
2021-08-12 17:57:02 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--background-loop-offset",
|
|
|
|
type=int,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"The very first frame of the background animation. Specify this to start the first loop anywhere other than the "
|
|
|
|
"loop start frame. For example, if your background animation has 10 frames and you specify a loop offset of 7, the "
|
|
|
|
"resulting background animation loop will contain frames 7, 8, 9 10, 1, 2, 3, 4, 5 and 6 played in that order. Note "
|
|
|
|
"that this can work in conjunction with the --background-loop-start and --background-loop-end parameters. For "
|
|
|
|
"example, if your background animation has 10 frames and you specify a loop start of 3, a loop end of 7 and a loop "
|
|
|
|
"offset of 5, the resulting background animation loop will contain frames 5, 6, 7, 3 and 4 played in that order."
|
|
|
|
),
|
2021-08-11 23:40:38 +02:00
|
|
|
)
|
2021-05-16 21:40:06 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--only-depths",
|
|
|
|
type=str,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Only render objects on these depth planes. Specify a number, a comma-separated list of numbers or a range such as 3-5.",
|
2021-05-25 04:01:36 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--only-frames",
|
|
|
|
type=str,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Only render these frames. Specify a number, a comma-separated list of numbers or a range such as 10-20. Note that the "
|
|
|
|
"first frame is frame 1, not frame 0."
|
|
|
|
),
|
2021-05-16 21:40:06 +02:00
|
|
|
)
|
2021-06-13 23:38:41 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--force-width",
|
|
|
|
type=int,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Force the width of the rendered animation to a specific pixel value, such as 640 or 800. If the forced width does not match "
|
|
|
|
"the animation's original width it will be stretched horizontally."
|
|
|
|
),
|
2021-06-13 23:38:41 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--force-height",
|
|
|
|
type=int,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Force the height of the rendered animation to a specific pixel value, such as 480 or 600. If the forced height does not match "
|
|
|
|
"the animation's original height it will be stretched vertically."
|
|
|
|
),
|
2021-06-13 23:38:41 +02:00
|
|
|
)
|
2021-05-21 23:31:13 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--force-aspect-ratio",
|
|
|
|
type=str,
|
|
|
|
default=None,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Force the aspect ratio of the rendered animation, as a colon-separated aspect ratio such as 16:9 or 4:3, after applying "
|
|
|
|
"any forced width and height. The resulting animation will be stretched to meet the requested aspect ratio."
|
|
|
|
),
|
2021-05-21 23:31:13 +02:00
|
|
|
)
|
2021-05-21 23:31:39 +02:00
|
|
|
render_parser.add_argument(
|
|
|
|
"--scale-width",
|
|
|
|
type=float,
|
|
|
|
default=1.0,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Scale the final width of the animation by some factor, such as 2.0 or 0.5, after applying any forced width, height and "
|
|
|
|
"aspect ratio. The resulting animation will be stretched horizontally by the scaling factor."
|
|
|
|
),
|
2021-05-21 23:31:39 +02:00
|
|
|
)
|
|
|
|
render_parser.add_argument(
|
|
|
|
"--scale-height",
|
|
|
|
type=float,
|
|
|
|
default=1.0,
|
2021-08-12 17:57:37 +02:00
|
|
|
help=(
|
|
|
|
"Scale the final height of the animation by some factor, such as 2.0 or 0.5, after applying any forced width, height and "
|
|
|
|
"aspect ratio. The resulting animation will be stretched vertically by the scaling factor."
|
|
|
|
),
|
2021-04-21 03:06:48 +02:00
|
|
|
)
|
2021-05-30 06:16:25 +02:00
|
|
|
render_parser.add_argument(
|
2021-05-31 20:10:25 +02:00
|
|
|
"--enable-anti-aliasing",
|
2021-05-30 06:16:25 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Enable anti-aliased rendering, using bilinear interpolation and super-sampling where appropriate to produce the best resulting animation.",
|
2021-08-03 19:03:59 +02:00
|
|
|
)
|
2021-04-16 01:18:33 +02:00
|
|
|
|
2021-08-12 17:57:37 +02:00
|
|
|
list_parser = subparsers.add_parser(
|
|
|
|
'list',
|
|
|
|
help='List out the possible paths to render from a collection of TXP2 or IFS containers',
|
|
|
|
description='List out the possible paths to render from a collection of TXP2 or IFS containers.',
|
|
|
|
)
|
2021-04-16 23:08:41 +02:00
|
|
|
list_parser.add_argument(
|
|
|
|
"container",
|
|
|
|
metavar="CONTAINER",
|
|
|
|
type=str,
|
|
|
|
nargs='+',
|
2021-08-12 17:57:37 +02:00
|
|
|
help="A container to use for loading animation data. Can be either a TXP2 or IFS container.",
|
2021-04-16 23:08:41 +02:00
|
|
|
)
|
2021-05-29 05:41:25 +02:00
|
|
|
list_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"-v",
|
|
|
|
"--verbose",
|
2021-05-29 05:41:25 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Display verbose debugging output",
|
2021-05-29 05:41:25 +02:00
|
|
|
)
|
|
|
|
list_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"--include-frames",
|
2021-05-29 05:41:25 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Include number of frames per animation in the output list.",
|
2021-05-29 05:41:25 +02:00
|
|
|
)
|
2021-04-16 23:08:41 +02:00
|
|
|
list_parser.add_argument(
|
2021-08-12 17:57:37 +02:00
|
|
|
"--include-size",
|
2021-04-16 23:08:41 +02:00
|
|
|
action="store_true",
|
2021-08-12 17:57:37 +02:00
|
|
|
help="Include width/height per animation in the output list.",
|
2021-04-16 23:08:41 +02:00
|
|
|
)
|
2021-05-16 17:15:06 +02:00
|
|
|
|
2020-11-06 03:08:21 +01:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
2020-11-11 04:39:51 +01:00
|
|
|
if args.action == "extract":
|
2021-05-29 05:41:03 +02:00
|
|
|
return extract_txp2(
|
|
|
|
args.file,
|
|
|
|
args.dir,
|
|
|
|
split_textures=args.split_textures,
|
|
|
|
generate_mapping_overlays=args.generate_mapping_overlays,
|
|
|
|
write_mappings=args.write_mappings,
|
|
|
|
write_raw=args.write_raw,
|
|
|
|
write_binaries=args.write_binaries,
|
|
|
|
pretend=args.pretend,
|
|
|
|
verbose=args.verbose,
|
|
|
|
)
|
|
|
|
elif args.action == "update":
|
|
|
|
return update_txp2(args.file, args.dir, pretend=args.pretend, verbose=args.verbose)
|
|
|
|
elif args.action == "print":
|
|
|
|
return print_txp2(args.file, decompile_bytecode=args.decompile_bytecode, verbose=args.verbose)
|
|
|
|
elif args.action == "parseafp":
|
|
|
|
return parse_afp(args.afp, args.bsi, decompile_bytecode=args.decompile_bytecode, verbose=args.verbose)
|
|
|
|
elif args.action == "decompile":
|
|
|
|
return decompile_afp(args.afp, args.bsi, args.directory, decompile_bytecode=args.decompile_bytecode, verbose=args.verbose)
|
|
|
|
elif args.action == "parsegeo":
|
|
|
|
return parse_geo(args.geo, verbose=args.verbose)
|
|
|
|
elif args.action == "list":
|
2021-05-29 05:41:25 +02:00
|
|
|
return list_paths(args.container, include_size=args.include_size, include_frames=args.include_frames, verbose=args.verbose)
|
2021-05-29 05:41:03 +02:00
|
|
|
elif args.action == "render":
|
|
|
|
return render_path(
|
|
|
|
args.container,
|
|
|
|
args.path,
|
|
|
|
args.output,
|
|
|
|
disable_threads=args.disable_threads,
|
2021-05-31 20:10:25 +02:00
|
|
|
enable_anti_aliasing=args.enable_anti_aliasing,
|
2021-05-29 05:41:03 +02:00
|
|
|
background_color=args.background_color,
|
|
|
|
background_image=args.background_image,
|
2021-08-11 23:40:38 +02:00
|
|
|
background_loop_start=args.background_loop_start,
|
|
|
|
background_loop_end=args.background_loop_end,
|
2021-08-12 17:57:02 +02:00
|
|
|
background_loop_offset=args.background_loop_offset,
|
2021-06-13 23:38:41 +02:00
|
|
|
force_width=args.force_width,
|
|
|
|
force_height=args.force_height,
|
2021-05-29 05:41:03 +02:00
|
|
|
force_aspect_ratio=args.force_aspect_ratio,
|
|
|
|
scale_width=args.scale_width,
|
|
|
|
scale_height=args.scale_height,
|
|
|
|
only_depths=args.only_depths,
|
|
|
|
only_frames=args.only_frames,
|
2021-08-03 19:03:59 +02:00
|
|
|
show_progress=args.show_progress,
|
2021-05-29 05:41:03 +02:00
|
|
|
verbose=args.verbose,
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
raise Exception(f"Invalid action {args.action}!")
|
2020-11-06 03:08:21 +01:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
sys.exit(main())
|