From 000feb1d16071d7cf1009d39afb77fbe5a75e158 Mon Sep 17 00:00:00 2001 From: ilonachan Date: Sun, 1 Sep 2024 20:44:29 +0200 Subject: [PATCH] Upgrade to GDA syntax, command documentation --- data/commands.json | 253 +++++++++++++++++++++++++++++++++++++++++++-- formats/gds.py | 156 +++++++++++++++++++--------- 2 files changed, 354 insertions(+), 55 deletions(-) diff --git a/data/commands.json b/data/commands.json index c88bdc1..857e390 100644 --- a/data/commands.json +++ b/data/commands.json @@ -1,10 +1,245 @@ { - "engine": 27, - "img_win": 31, - "puzzle_title": 186, - "puzzle_picarats": 195, - "circle_answer": 212, - "puzzle_info": 220, - "input_answer": 251, - "input_bg": 252 -} \ No newline at end of file + "engine": { + "id": 27, + "desc": "Called at the start of a puzzle script. Determines which puzzle engine to use.", + "params": [ + { "type": "string", "desc": "Engine name (from a predefined list)" } + ] + }, + "img_win": { + "id": 31, + "desc": "Seemingly denotes when a special top-screen image should be shown on the puzzle completion screen.", + "params": [{ "type": "int" }, { "type": "int" }] + }, + "puzzle_title": { + "id": 186, + "desc": "Defines the title of a puzzle. Called centrally as a list in `puzzletitle/qtitle.gds`.", + "params": [ + { "type": "int", "desc": "Puzzle ID" }, + { "type": "string", "desc": "Puzzle Title" } + ] + }, + "puzzle_picarats": { + "id": 195, + "desc": "Defines how much picarat a puzzle awards. Called centrally in `puzzletitle/pscript.gds`.", + "params": [ + { "type": "int", "desc": "Puzzle ID" }, + { "type": "int", "desc": "Value on first attempt" }, + { "type": "int", "desc": "Value on second attempt" }, + { "type": "int", "desc": "Value on all further attempts" } + ] + }, + "puzzle_info": { + "id": 220, + "desc": "Defines additional information about the puzzle displayed in the puzzle index. Some puzzle IDs ", + "params": [ + { "type": "int", "desc": "Puzzle ID" }, + { + "type": "string", + "desc": "Puzzle type (e.g. \"Write Answer\" or \"Draw Line\", but can be anything)" + }, + { + "type": "string", + "desc": "Puzzle original location (should be a recognizable map name, but can technically be anything, such as \"Layton's Challenges\")" + } + ] + }, + "circle_answer": { "id": 212 }, + "input_answer": { "id": 251 }, + "input_bg": { "id": 252 }, + "dog_unknown": { + "id": 194, + "desc": "Used in the Gizmo minigame, presumably to manage the placement of the dog parts", + "params": [ + { "type": "int", "desc": "Between 1-20, probably the part index?" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int", "desc": "Always the same as param 1" }, + { "type": "int" }, + { "type": "int" } + ] + }, + "inn_hint_layton": { + "id": 202, + "desc": "Used in the Inn minigame to display the Professor's assessment of the room layout.", + "params": [ + { + "type": "string", + "desc": "The text to be displayed" + }, + { + "type": "int", + "desc": "Increments with the quality of the room (unsure about effect)" + }, + { + "type": "int", + "desc": "Always 0" + } + ] + }, + "inn_hint_luke": { + "id": 203, + "desc": "Used in the Inn minigame to display Luke's assessment of the room layout.", + "params": [ + { + "type": "string", + "desc": "The text to be displayed" + }, + { + "type": "int", + "desc": "Increments with the quality of the room (unsure about effect)" + }, + { + "type": "int", + "desc": "Always 0" + } + ] + }, + "inn_item": { + "id": 207, + "desc": "Used in the hotelscript to define the available Inn items. All used at the end for some reason.", + "params": [ + { "type": "int", "desc": "item ID" }, + { "type": "string", "desc": "Name of the item" } + ] + }, + "inn_item_layton": { + "id": 188, + "desc": "Used in the hotelscript, always followed by `inn_item_layton_comment` and `inn_item_layton_comment2`, likely to define Inn item placements in Layton's room.", + "params": [ + { "type": "int", "desc": "item ID" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" } + ] + }, + "inn_item_layton_comment": { + "id": 198, + "desc": "Used in the hotelscript after `inn_item_layton`. Defines the Professor's comment about an Inn minigame item.", + "params": [{ "type": "string", "desc": "The Professor's comment" }] + }, + "inn_item_layton_comment2": { + "id": 199, + "desc": "Used in the hotelscript after `inn_item_layton`. Defines the comment made by the professor if a second item of the same type is being added, along with the ID of the item that is the 'same type'.", + "params": [ + { "type": "string", "desc": "The Professor's comment" }, + { + "type": "int", + "desc": "The ID of the item that's considered a duplicate of this one's type, or -1 if there is no such item. (not sure which of the two comments is used in that case)" + } + ] + }, + "inn_item_luke": { + "id": 189, + "desc": "Used in the hotelscript, always followed by `inn_item_luke_comment` and `inn_item_luke_comment2`, likely to define Inn item placements in Luke's room." + }, + "inn_item_luke_comment": { + "id": 200, + "desc": "Used in the hotelscript with `inn_item_luke`. Defines Luke's comment about an Inn minigame item.", + "params": [{ "type": "string", "desc": "Luke's comment" }] + }, + "inn_item_luke_comment2": { + "id": 201, + "desc": "Used in the hotelscript with `inn_item_luke`. Defines the comment made by Luke if a second item of the same type is being added, along with the ID of the item that is the 'same type'.", + "params": [ + { "type": "string", "desc": "Luke's comment" }, + { + "type": "int", + "desc": "The ID of the item that's considered a duplicate of this one's type, or -1 if there is no such item. (not sure which of the two comments is used in that case)" + } + ] + }, + "nazobaba": { + "id": 229, + "desc": "Used in the Granny Riddleton script. One of these commands is used for every puzzle, possibly to define after which story progression point a puzzle gets sent to the cottage. All but one of the three unknown params are always -1, and sometimes all are (presumably for puzzles that never get sent to the cottage). Which value is present may determine the bottle color, and the value may determine after which progression point the puzzle shows up.", + "params": [ + { "type": "int", "desc": "Puzzle ID" }, + { "type": "int" }, + { "type": "int" }, + { "type": "int" } + ] + }, + + "slide2_grid_dimensions": { + "id": 164, + "desc": "Defines the grid dimensions in a 'Slide Puzzle 2'.", + "params": [ + { "type": "int", "desc": "grid width" }, + { "type": "int", "desc": "grid height" } + ] + }, + "slide2_grid_offset": { + "id": 163, + "desc": "Determines the offset of the grid's visual placement relative to the touch screen's top left corner (0,0). Used to align the puzzle itself with the background.", + "params": [ + { "type": "int", "desc": "x offset in px" }, + { "type": "int", "desc": "y offset in px" } + ] + }, + "slide2_grid_cellsize": { + "id": 165, + "desc": "Defines the visual scale of the grid by the width and height of the cells. This puzzle engine only allows square cells.", + "params": [ + { + "type": "int", + "desc": "width & height of the square grid cells in px" + } + ] + }, + "slide2_item": { + "id": 166, + "desc": "Place an item in the grid of a 'Slide Puzzle 2'", + "params": [ + { "type": "int", "desc": "x coordinate" }, + { "type": "int", "desc": "y coordinate" }, + { + "type": "int", + "desc": "unknown, but likely to do with properties such as draggability & obstacle behavior" + }, + { + "type": "string", + "desc": "Item sprite name, from `ani/slidepuzzle2.arc`. 'x' in particular has no image associated, and will show the background through (used for walls)." + } + ] + }, + "slide2_goalid": { + "id": 177, + "desc": "Defines a grid cell as the goal for a specific item type index. These indices seem to be attached to objects adhoc, but an object with each goal's specified index must be placed on all goal cells defined like this in order for the puzzle to solve.", + "params": [ + { "type": "int", "desc": "x coordinate" }, + { "type": "int", "desc": "y coordinate" }, + { "type": "int", "desc": "Item type index" } + ] + }, + "slide2_item_goalid": { + "id": 178, + "desc": "Following a `slide2_item` definition, attaches the given item type index to that object. Used to determine if the puzzle has been solved.", + "params": [ + { + "type": "int", + "desc": "Item type index (can seemingly be chosen adhoc to be anything)" + } + ] + }, + "slide2_item_goalcell": { + "id": 56, + "desc": "Following a `slide2_item` definition, sets a target location at which the cell needs to be placed for the puzzle to be solved." + }, + + "placetarget": { + "id": 94, + "desc": "Main definition of a 'Place Target' puzzle", + "params": [ + {"type": "int", "desc": "Target x position"}, + {"type": "int", "desc": "Target y position"}, + {"type": "string", "desc": "Sprite to be displayed at the touched location"}, + {"type": "unknown-2"}, + {"type": "int"} + ] + } +} diff --git a/formats/gds.py b/formats/gds.py index 769018a..e4c9b2c 100644 --- a/formats/gds.py +++ b/formats/gds.py @@ -3,6 +3,7 @@ import os import parse +import ast from version import v @click.group(help="Script-like format, also used to store puzzle parameters.",options_metavar='') @@ -11,30 +12,17 @@ def cli(): dir_path = "/".join(os.path.dirname(os.path.realpath(__file__).replace("\\", "/")).split("/")[:-1]) commands = json.load(open(f"{dir_path}/data/commands.json", encoding="utf-8")) -commands_i = {val: key for key, val in commands.items()} # Inverted version of commands - -class GDSModeException (Exception): - def __init__(self, mode): - self.mode = mode - super().__init__( - f"'{mode}' is not a valid mode for GDS.__init__(): must be one of 'bin', 'json', 'gda'") +commands_i = {val["id"]: key for key, val in commands.items() if "id" in val} # Inverted version of commands class GDS: - def __init__(self, file, mode="bin"): #modes: "bin"/"b", "json"/"j", "gda"/"a" - if mode == "bin" or mode == "b": - self.from_gds(file) - elif mode == "json" or mode == "j": - self.from_json(file) - elif mode == "gda" or mode == "a": - self.from_old(file) - else: - raise GDSModeException(mode) - - def from_gds(self, file): + def __init__(self, cmds = []): #modes: "bin"/"b", "json"/"j", "gda"/"a" + self.cmds = cmds + + @classmethod + def from_gds(Self, file): length = int.from_bytes(file[0:4], "little") if file[4:6] == b"\x0c\x00": - self.cmds = [] - return + return Self([]) cmd_data = file[6:length+4] cmds = [] @@ -90,30 +78,44 @@ def from_gds(self, file): else: raise Exception(f"GDS file error: Invalid or unsupported parameter type {hex(p_type)}!") - self.cmds = cmds + return Self(cmds) - def from_json (self, file): - self.cmds = json.loads(file)["data"] + @classmethod + def from_json (Self, file): + cmds = json.loads(file) #TODO: reject non-compatible json files + return Self(cmds) - def from_old (self, file): #TODO: make this, so gds_old can be completely removed + @classmethod + def from_gda (Self, file): #TODO: make this, so gds_old can be completely removed cmds = [] for line in file.split("\n"): - data = {} + line = line.strip() if line.startswith("#"): continue + if line == '': + continue + + data = {} line, strings = parse.remove_strings(line) line = line.rstrip().split(" ") cmd = line[0] - if cmd == "engine": - cmd = "0x1b" - elif cmd == "img_win": - cmd = "0x1f" + # TODO: handle inline comments, maybe + + if cmd in commands: + cmd = commands[cmd] + if "alias" in cmd: + cmd = commands[cmd["alias"]] + elif cmd.startswith("cmd_"): + cmd = int(cmd[4:]) + elif cmd.startswith("0x"): + cmd = int(cmd[2:], base=16) + else: + raise Exception(f"Unknown GDA command: {cmd}") - cmd = int(cmd[2:], base=16) params = [] for param in line[1:]: @@ -122,14 +124,24 @@ def from_old (self, file): #TODO: make this, so gds_old can be completely remove elif param.startswith("0x"): params.append({"type":"unknown-2", "data":int(param[2:], 16)}) elif param.startswith('"') and param.endswith('"'): - param = strings[int(param[1:-1])] + param = ast.literal_eval(f'"{strings[int(param[1:-1])]}"') params.append({"type":"string", "data":param}) + elif param.startswith("!6("): + params.append({"type":"unknown-6", "data":int(param[3:-1], 16)}) + elif param.startswith("!7("): + params.append({"type":"unknown-7", "data":int(param[3:-1], 16)}) + elif param.startswith("!8"): + params.append({"type":"unknown-8"}) + elif param.startswith("!9"): + params.append({"type":"unknown-9"}) + elif param.startswith("!b"): + params.append({"type":"unknown-b"}) else: raise Exception(f"Invalid GDA parameter: {param}") cmds.append({"command":cmd, "parameters":params}) - self.cmds = cmds + return Self(cmds) def __getitem__ (self, index): index = int(index) @@ -141,10 +153,10 @@ def to_json (self): def to_gds (self): out = b"\x00" * 2 for command in self.cmds: - if type(command["command"]) == int: - out += command["command"].to_bytes(2, "little") + if type(command["command"]["id"]) == int: + out += command["command"]["id"].to_bytes(2, "little") else: - out += commands[command["command"]].to_bytes(2, "little") + out += commands[command["command"]["id"]].to_bytes(2, "little") for param in command["parameters"]: if param["type"] == "int": out += b"\x01\x00" @@ -177,10 +189,42 @@ def to_gds (self): def to_bin (self): #alias return self.to_gds() + + def to_gda(self): + out = "" + for command in self.cmds: + if type(command["command"]) == int: + out += "0x"+command["command"].to_bytes(1, "little").hex() + else: + out += command['command'] + for param in command["parameters"]: + out += " " + if param["type"] == "int": + out += str(param["data"]) + elif param["type"] == "string": + out += repr(param["data"]) + elif param["type"] == "unknown-2": + out += hex(param["data"]) + elif param["type"] == "unknown-6": + b = param["data"].to_bytes(4, "little") + out += f"!6({b.hex()})" + elif param["type"] == "unknown-7": + b = param["data"].to_bytes(4, "little") + out += f"!7({b.hex()})" + elif param["type"] == "unknown-8": + out += "!8" + elif param["type"] == "unknown-9": + out += "!9" + elif param["type"] == "unknown-b": + out += "!b" + else: + raise Exception(f"GDA error: invalid or unsupported parameter type '{param['type']}'!") + out += "\n" + return out @cli.command( - name="extract", - help="Converts a GDS script to JSON.", + name="dumpjson", + help="Parses a GDS script file and dumps its contents in a JSON document.", no_args_is_help = True ) @click.argument("input") @@ -188,13 +232,13 @@ def to_bin (self): #alias def unpack_json(input, output): input = open(input, "rb").read() output = open(output, "w", encoding="utf-8") - gds = GDS(input) + gds = GDS.from_gds(input) output.write(gds.to_json()) output.close() @cli.command( - name="create", - help="Converts a JSON to GDS.", + name="compilejson", + help="Converts a JSON document created by 'compilejson' to a GDS script file.", no_args_is_help = True ) @click.argument("input") @@ -203,21 +247,41 @@ def create_json(input, output): input = open(input, encoding="utf-8").read() output = open(output, "wb") - gds = GDS(input, "json") + gds = GDS.from_json(input) output.write(gds.to_bin()) output.close() @cli.command( - name="gdaimport", - help="Creates a GDS JSON file from the old GDA format.", + name="compile", + help="Generates a GDS binary script file from human-readable GDA files.", no_args_is_help = True ) @click.argument("input") @click.argument("output") def create_from_gda(input, output): input = open(input, encoding="utf-8").read() - output = open(output, "w", encoding="utf-8") + output = open(output, "wb") - gds = GDS(input, "gda") - output.write(gds.to_json()) + gds = GDS.from_gda(input) + output.write(gds.to_bin()) + output.close() + +@cli.command( + name="decompile", + help="Converts a GDS file into a human-readable GDA script format.", + no_args_is_help = True + ) +@click.argument("input") +@click.argument("output", required=False) +def create_to_gda(input, output = None): + if output is None: + output = input + if output.lower().endswith(".gds"): + output = output[:-4] + output = output + ".gda" + + input = open(input, "rb").read() + output = open(output, "w", encoding="utf-8") + gds = GDS.from_gds(input) + output.write(gds.to_gda()) output.close()