Last active
December 3, 2024 18:32
-
-
Save osyu/5bb86d49153edef5415a7aba09a48ca1 to your computer and use it in GitHub Desktop.
GS456 (AJ:AA Trilogy) script converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import argparse | |
| import glob | |
| import json | |
| import os.path | |
| import shutil | |
| import traceback | |
| DESCRIPTION = """Encode and decode GS456 (AJ:AA Trilogy) script files.""" | |
| USRHDR_SIZE = 48 | |
| CLASS_GS4 = (0xdaa48445, 0x5212daa2) | |
| CLASS_GS56 = (0x83f3f042, 0x0b263156) | |
| CLASS_NAME = (0xee933aa7, 0x1aa1a4ac) | |
| read_int = lambda f, l: int.from_bytes(f.read(l), 'little') | |
| write_int = lambda f, l, x: f.write(x.to_bytes(l, 'little')) | |
| read_str = lambda f, l: f.read(l * 2).decode('utf-16le')[:-1] | |
| write_str = lambda f, x: f.write((x + '\0').encode('utf-16le')) | |
| round_up = lambda x, l: (x + l - 1) // l * l | |
| seek_pad = lambda f, l: f.seek(round_up(f.tell(), l)) | |
| def encode(f): | |
| is_gs56 = False | |
| if f.name.endswith('.json'): | |
| is_gs56 = True | |
| data = json.load(f) | |
| try: | |
| assert isinstance(data['name'], str) | |
| assert isinstance(data['labels'], list) | |
| for l in data['labels']: | |
| assert isinstance(l, list) | |
| assert len(l) == 2 | |
| assert all(isinstance(x, str) for x in l) | |
| except AssertionError as e: | |
| raise ValueError("incorrect json structure") from e | |
| elif not f.name.endswith('.bin'): | |
| raise ValueError( | |
| "unknown file extension (must be .bin or .json)") | |
| of = open(f.name.rsplit('.', 1)[0], 'wb') | |
| of.write(b'USR\0') # magic | |
| for i in range(3): # resource, userdata and info counts | |
| write_int(of, 4, 0) | |
| for i in range(3): # resource, userdata and data offsets | |
| write_int(of, 8, USRHDR_SIZE) | |
| seek_pad(of, 16) | |
| of.write(b'RSZ\0') # magic | |
| write_int(of, 4, 16) # version | |
| write_int(of, 4, 1) # object count | |
| instance_count = len(data['labels']) + 1 if is_gs56 else 1 | |
| write_int(of, 4, instance_count + 1) | |
| write_int(of, 4, 0) # userdata count | |
| write_int(of, 4, 0) # reserved | |
| write_int(of, 8, 52) # instance offset | |
| data_offset = round_up(52 + (8 * (instance_count + 1)), 16) | |
| write_int(of, 8, data_offset) # data offset | |
| write_int(of, 8, data_offset) # userdata offset | |
| write_int(of, 4, instance_count) # object table | |
| write_int(of, 8, 0) # null | |
| if is_gs56: | |
| for i in range(instance_count - 1): | |
| write_int(of, 4, CLASS_GS56[0]) | |
| write_int(of, 4, CLASS_GS56[1]) | |
| write_int(of, 4, CLASS_NAME[0]) | |
| write_int(of, 4, CLASS_NAME[1]) | |
| else: | |
| write_int(of, 4, CLASS_GS4[0]) | |
| write_int(of, 4, CLASS_GS4[1]) | |
| seek_pad(of, 16) | |
| if is_gs56: | |
| for l in data['labels']: | |
| write_int(of, 4, len(l[0]) + 1) | |
| write_str(of, l[0]) | |
| seek_pad(of, 4) | |
| write_int(of, 4, len(l[1]) + 1) | |
| write_str(of, l[1]) | |
| seek_pad(of, 4) | |
| write_int(of, 4, len(data['name']) + 1) | |
| write_str(of, data['name']) | |
| seek_pad(of, 4) | |
| write_int(of, 4, instance_count - 1) | |
| for i in range(instance_count - 1): | |
| write_int(of, 4, i + 1) | |
| else: | |
| f.seek(0, 2) | |
| size = f.tell() | |
| f.seek(0) | |
| write_int(of, 4, size) | |
| shutil.copyfileobj(f, of) | |
| of.close() | |
| f.close() | |
| def decode(f): | |
| assert f.read(4) == b'USR\0' # magic | |
| for i in range(3): # resource, userdata and info counts | |
| assert read_int(f, 4) == 0 | |
| for i in range(3): # resource, userdata and data offsets | |
| assert read_int(f, 8) == USRHDR_SIZE | |
| seek_pad(f, 16) | |
| assert f.tell() == USRHDR_SIZE | |
| assert f.read(4) == b'RSZ\0' # magic | |
| assert read_int(f, 4) == 16 # version | |
| assert read_int(f, 4) == 1 # object count | |
| instance_count = read_int(f, 4) - 1 | |
| assert read_int(f, 4) == 0 # userdata count | |
| assert read_int(f, 4) == 0 # reserved | |
| instance_offset = read_int(f, 8) | |
| data_offset = read_int(f, 8) | |
| assert read_int(f, 8) == data_offset # userdata offset | |
| assert read_int(f, 4) == instance_count # object table | |
| assert f.tell() == USRHDR_SIZE + instance_offset | |
| assert instance_count != 0 | |
| assert read_int(f, 8) == 0 # null | |
| is_gs56 = False | |
| for i in range(instance_count): | |
| type_id = read_int(f, 4) | |
| crc = read_int(f, 4) | |
| cls = (type_id, crc) | |
| if cls == CLASS_GS4: | |
| if instance_count != 1: | |
| raise ValueError("gs4 class when >1 instance") | |
| elif cls == CLASS_GS56: | |
| if i == instance_count - 1: | |
| raise ValueError("gs56 class at last instance") | |
| is_gs56 = True | |
| elif cls == CLASS_NAME: | |
| if i != instance_count - 1: | |
| raise ValueError("name class before last instance") | |
| is_gs56 = True | |
| else: | |
| raise ValueError("unknown class") | |
| seek_pad(f, 16) | |
| assert f.tell() == USRHDR_SIZE + data_offset | |
| out = f.name + ('.json' if is_gs56 else '.bin') | |
| if is_gs56: | |
| data = {'name': None, 'labels': []} | |
| for i in range(instance_count - 1): | |
| label_size = read_int(f, 4) | |
| label = read_str(f, label_size) | |
| seek_pad(f, 4) | |
| text_size = read_int(f, 4) | |
| text = read_str(f, text_size) | |
| seek_pad(f, 4) | |
| data['labels'].append((label, text)) | |
| name_size = read_int(f, 4) | |
| data['name'] = read_str(f, name_size) | |
| seek_pad(f, 4) | |
| assert read_int(f, 4) == instance_count - 1 | |
| for i in range(instance_count - 1): | |
| assert read_int(f, 4) == i + 1 | |
| with open(out, 'w', encoding='utf-8', newline='\n') as of: | |
| json.dump(data, of, indent=2, ensure_ascii=False) | |
| of.write('\n') | |
| else: | |
| size = read_int(f, 4) | |
| cur = f.tell() | |
| f.seek(0, 2) | |
| assert size == f.tell() - cur | |
| f.seek(cur) | |
| with open(out, 'wb') as of: | |
| shutil.copyfileobj(f, of) | |
| f.close() | |
| if __name__ == '__main__': | |
| parser = argparse.ArgumentParser(description=DESCRIPTION, | |
| formatter_class=argparse.RawTextHelpFormatter) | |
| subparsers = parser.add_subparsers(dest='command', | |
| help="command (encode/decode)") | |
| enc_parser = subparsers.add_parser('e') | |
| enc_parser.add_argument('file', type=str, | |
| help="path to input file(s); accepts wildcard") | |
| dec_parser = subparsers.add_parser('d') | |
| dec_parser.add_argument('file', type=str, | |
| help="path to input file(s); accepts wildcard") | |
| mappings = {'e': encode, 'd': decode} | |
| args = parser.parse_args() | |
| if args.command in mappings: | |
| paths = glob.glob(args.file) | |
| if not paths: | |
| raise FileNotFoundError( | |
| 'no such file %s' % repr(args.file)) | |
| for p in paths: | |
| if os.path.isdir(p): | |
| continue | |
| try: | |
| mappings[args.command](open(p, 'rb')) | |
| except Exception as e: | |
| print("error with file %s:\n%s" % ( | |
| p, traceback.format_exc())) | |
| else: | |
| parser.print_help() |
Author
AJ's scripts are in a binary format similar to the one used on the DS, and they can be converted to text and back with this tool:
https://github.com/niltwill/capcom-mods/blob/main/scripts/ajaat-gs4-script.py
DD and SOJ's scripts use a much simpler format though, so gs456scr converts them directly to JSON without needing the second tool
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hello, once again. I don't understand how your script works a little bit. It converts .user.2. in BIN files, but then how to use and, most importantly, edit BIN files? Notepad++ outputs this:

Since BIN can store different formats, from game disk images to configurations, I don't understand what to do with the final file. Can you tell me how to edit the final file?