diff --git a/Pack/MKWFaraphel/ct_config.json b/Pack/MKWFaraphel/ct_config.json index aaca3f5..fae4403 100644 --- a/Pack/MKWFaraphel/ct_config.json +++ b/Pack/MKWFaraphel/ct_config.json @@ -5,7 +5,7 @@ "game_variant":"60", "region":5500, "cheat_region":20061, - "prefix_list":[ + "tags_prefix":[ "Wii U", "3DS", "DS", @@ -57,7 +57,7 @@ "GK7", "FGKR" ], - "suffix_list":[ + "tags_suffix":[ "Boost" ], "tags_color":{ @@ -114,7 +114,9 @@ "GK3":"green", "GK7":"green" }, + "tag_retro":"Retro", + "default_track":{ "music":"T32", "special":"T32", @@ -1953,8 +1955,8 @@ ] } ], - "!default_sort":"name", - "tracks_list":[ + + "tracks":[ { "name":"4IT Clown's Road", "author":[ diff --git a/source/mkw/ModConfig.py b/source/mkw/ModConfig.py index e69de29..0e3a9ba 100644 --- a/source/mkw/ModConfig.py +++ b/source/mkw/ModConfig.py @@ -0,0 +1,81 @@ +from pathlib import Path +from typing import Generator + +from source.mkw import Tag, Color +from source.mkw.Track import Track +import json + + +# representation of the configuration of a mod +class ModConfig: + __slots__ = ("name", "nickname", "variant", "region", "tags_prefix", "tags_suffix", "tag_retro", + "default_track", "_tracks", "version", "original_track_prefix", "swap_original_order", + "keep_original_track", "enable_random_cup") + + def __init__(self, name: str, nickname: str = None, version: str = None, variant: str = None, + tags_prefix: dict[Tag, Color] = None, tags_suffix: dict[Tag, Color] = None, + region: dict[int] | int = None, tag_retro: Tag = None, default_track: "Track | TrackGroup" = None, + tracks: list["Track | TrackGroup"] = None, original_track_prefix: bool = None, + swap_original_order: bool = None, keep_original_track: bool = None, enable_random_cup: bool = None): + + self.name: str = name + self.nickname: str = nickname if nickname is not None else name + self.version: str = version if version is not None else "1.0.0" + self.variant: str = variant if variant is not None else "01" + self.region: dict[int] | int = region if region is not None else 0 + + self.tags_prefix: dict[str] = tags_prefix if tags_prefix is not None else {} + self.tags_suffix: dict[str] = tags_suffix if tags_suffix is not None else {} + self.tag_retro: str = tag_retro if tag_retro is None else "Retro" + + self.default_track: "Track | TrackGroup" = default_track if default_track is not None else None + self._tracks: list["Track | TrackGroup"] = tracks if tracks is not None else [] + + self.original_track_prefix: bool = original_track_prefix if original_track_prefix is not None else True + self.swap_original_order: bool = swap_original_order if swap_original_order is not None else True + self.keep_original_track: bool = keep_original_track if keep_original_track is not None else True + self.enable_random_cup: bool = enable_random_cup if enable_random_cup is not None else True + + @classmethod + def from_dict(cls, config_dict: dict) -> "ModConfig": + """ + Create a ModConfig from a dict + :param config_dict: dict containing the configuration + :return: ModConfig + """ + + return cls( + name=config_dict["name"], + nickname=config_dict.get("nickname"), + version=config_dict.get("version"), + variant=config_dict.get("variant"), + tags_prefix=config_dict.get("tags_prefix"), + tags_suffix=config_dict.get("tags_suffix"), + tag_retro=config_dict.get("tag_retro"), + original_track_prefix=config_dict.get("original_track_prefix"), + swap_original_order=config_dict.get("swap_original_order"), + keep_original_track=config_dict.get("keep_original_track"), + enable_random_cup=config_dict.get("enable_random_cup"), + + default_track=Track.from_dict(config_dict.get("default_track", {})), + tracks=[Track.from_dict(track) for track in config_dict.get("tracks", [])], + ) + + @classmethod + def from_file(cls, config_file: str | Path) -> "ModConfig": + """ + Create a ModConfig from a file + :param config_file: file containing the configuration + :return: ModConfig + """ + if isinstance(config_file, str): config_file = Path(config_file) + return cls.from_dict(json.loads(config_file.read_text(encoding="utf8"))) + + def get_tracks(self) -> Generator["Track", None, None]: + """ + Get all the track elements + :return: track elements + """ + for track in self._tracks: + yield from track.get_tracks() + diff --git a/source/mkw/Track.py b/source/mkw/Track.py index e69de29..6241900 100644 --- a/source/mkw/Track.py +++ b/source/mkw/Track.py @@ -0,0 +1,162 @@ +from typing import Generator +import re + +from source.mkw import Tag, Slot + +TOKEN_START = "{{" +TOKEN_END = "}}" + +common_token_map = { # these operators and function are considered safe to use in the template + operator: operator + for operator in + ["+", "-", "*", "/", "%", "**", ",", "(", ")", "[", "]", "==", "!=", "in", ">", "<", ">=", "<=", "and", "or", "&", + "|", "^", "~", "<<", ">>", "not", "is", "if", "else", "abs", "int", "bin", "hex", "oct", "chr", "ord", "len", + "str", "bool", "float", "round", "min", "max", "sum", "zip", "any", "all", "issubclass", "reversed", "enumerate", + "list", "sorted", "hasattr", "for", "range", "type", "isinstance", "repr", "None", "True", "False" + ] + +} | { # these methods are considered safe, except for the magic methods + f".{method}": f".{method}" + for method in dir(str) + dir(list) + dir(int) + dir(float) + if not method.startswith("__") +} + + +class TokenParsingError(Exception): + def __init__(self, token: str): + super().__init__(f"Invalid token while parsing track representation:\n{token}") + + +# representation of a custom track +class Track: + def __init__(self, special: Slot = None, music: Slot = None, tags: list[Tag] = None, weight: int = None, **kwargs): + self.special: Slot = special if special is not None else "T11" + self.music: Slot = music if music is not None else "T11" + self.tags: list[Tag] = tags if tags is not None else [] + self.weight: int = weight if weight is not None else 1 + + # others not mandatory attributes + for key, value in kwargs.items(): + setattr(self, key, value) + + @classmethod + def from_dict(cls, track_dict: dict) -> "Track | TrackGroup": + """ + create a track from a dict, or create a track group is it is a group + :param track_dict: dict containing the track information + :return: Track + """ + if "group" in track_dict: + from source.mkw.TrackGroup import TrackGroup + return TrackGroup.from_dict(track_dict) + return cls(**track_dict) + + def get_tracks(self) -> Generator["Track", None, None]: + """ + Get all the track elements + :return: track elements + """ + for _ in range(self.weight): + yield self + + def repr_format(self, mod_config: "ModConfig", format: str) -> str: + """ + return the representation of the track from the format + :param mod_config: configuration of the mod + :return: formatted representation of the track + """ + + token_map = common_token_map | { # replace the suffix and the prefix by the corresponding values + "prefix": self.get_prefix(mod_config, ""), + "suffix": self.get_prefix(mod_config, ""), + } | { # replace the track attribute by the corresponding values + f"track.{attr}": f"track.{attr}" for attr, value in self.__dict__.items() + } | { # replace the track variable by the corresponding value, if not used with an attribute + "track": "track" + } + + def format_token(match: re.Match) -> str: + # get the token string without the brackets, then strip it + process_token = match.group(1).strip() + final_token: str = "" + + def matched(match: re.Match | str | None, value: str = None) -> bool: + """ + check if token is matched, if yes, add it to the final token and remove it from the processing token + :param match: match object + :param value: if the match is a string, the value to replace the text with + :return: True if matched, False otherwise + """ + nonlocal final_token, process_token + + # if there is no match or the string is empty, return False + if not match: return False + + if isinstance(match, re.Match): + process_token_raw = process_token[match.end():] + value = match.group() + + else: + if not process_token.startswith(match): return False + process_token_raw = process_token[len(match):] + + process_token = process_token_raw.lstrip() + + final_token += value + (len(process_token_raw) - len(process_token)) * " " + + return True + + while process_token: # while there is still tokens to process + # if the section is a string, add it to the final token + # example : "hello", "hello \" world" + if matched(re.match(r'^\"(?:[^"\\]|\\.)*\"', process_token)): + continue + + # if the section is a float or an int, add it to the final token + # example : 102, 102.59 + if matched(re.match(r'^[0-9]+(?:\.[0-9]+)?', process_token)): + continue + + # if the section is a variable, operator or function, replace it by its value + # example : track.special, + + for key, value in token_map.items(): + if matched(key, value): + break + + # else, the token is invalid, so raise an error + else: + raise TokenParsingError(process_token) + + # if final_token is set, eval final_token and return the result + if final_token: + return str(eval(final_token, {}, {"track": self})) + else: + return final_token + + # pass everything between TOKEN_START and TOKEN_END in the function + return re.sub(rf"{TOKEN_START}(.*?){TOKEN_END}", format_token, format) + + def get_prefix(self, mod_config: "ModConfig", default: any = None) -> any: + """ + return the prefix of the track + :param default: default value if no prefix is found + :param mod_config: mod configuration + :return: formatted representation of the track prefix + """ + for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags): + return mod_config.tags_prefix[tag] + return default + + def get_suffix(self, mod_config: "ModConfig", default: any = None) -> any: + """ + return the suffix of the track + :param default: default value if no suffix is found + :param mod_config: mod configuration + :return: formatted representation of the track suffix + """ + for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags): + return mod_config.tags_suffix[tag] + return default + + def get_highlight(self, mod_config: "ModConfig", default: any = None) -> any: + ... diff --git a/source/mkw/TrackGroup.py b/source/mkw/TrackGroup.py index e69de29..ce4cc86 100644 --- a/source/mkw/TrackGroup.py +++ b/source/mkw/TrackGroup.py @@ -0,0 +1,27 @@ +from typing import Generator + + +# representation of a group of tracks +class TrackGroup: + def __init__(self, tracks: list["Track"] = None): + self.tracks = tracks if tracks is not None else [] + + def get_tracks(self) -> Generator["Track", None, None]: + """ + Get all the track elements + :return: track elements + """ + for track in self.tracks: + yield from track.get_tracks() + + @classmethod + def from_dict(cls, group_dict: dict) -> "TrackGroup | Track": + """ + create a track group from a dict, or create a track from the dict if not a group + :param group_dict: dict containing the track information + :return: TrackGroup or Track + """ + from source.mkw.Track import Track + + if "group" not in group_dict: return Track.from_dict(group_dict) + return cls(tracks=[Track.from_dict(track) for track in group_dict["group"]]) diff --git a/source/mkw/__init__.py b/source/mkw/__init__.py index e69de29..1298592 100644 --- a/source/mkw/__init__.py +++ b/source/mkw/__init__.py @@ -0,0 +1,28 @@ +import enum + +Tag = str +Slot = str + + +Color = { + "yor7": 0xF5090B, # apple red + "yor6": 0xE82C09, # dark red + "yor5": 0xE65118, # dark orange (flame) + "yor4": 0xFF760E, # orange (pumpkin) + "yor3": 0xFFA61F, # light orange (bright yellow) + "yor2": 0xFEBC1F, # yellow (ripe mango) + "yor1": 0xFFE71F, # light yellow + "yor0": 0xFFFF22, # neon yellow + "blue2": 0x1170EC, # dark blue + "blue1": 0x75B5F6, # azure + "green": 0x0EB00A, # green + "yellow": 0xFFFD1E, # neon yellow 2 + "red4": 0xEE0C10, # vivid red + "red3": 0xFF0308, # red + "red2": 0xF14A4E, # light red + "red1": 0xE46C74, # pink + "white": 0xFFFFFF, # white + "clear": 0x000000, # clear + "off": 0x998C86 # off +} + diff --git a/source/wt/szs.py b/source/wt/szs.py index d432940..e415c05 100644 --- a/source/wt/szs.py +++ b/source/wt/szs.py @@ -4,10 +4,14 @@ tools_path = tools_szs_dir / "wszst.exe" class SZSPath: + __slots__ = ("path",) def __init__(self, path: Path | str): self.path: Path = path if isinstance(path, Path) else Path(path) + def __repr__(self): + return f"" + @better_error(tools_path) def _run(self, *args) -> bytes: """ @@ -37,8 +41,94 @@ class SZSPath: :param dest: destination path :return: the extracted file path """ - dest = dest if isinstance(dest, Path) else Path(dest) + return self[subfile].extract(dest) + + def extract_all(self, dest: Path | str) -> Path: + """ + Extract all the subfiles to a destination + :param dest: output directory + :return: + """ + dest: Path = dest if isinstance(dest, Path) else Path(dest) + if dest.is_dir(): dest /= self.path.name + + self._run("EXTRACT", self.path, "-d", dest) + return dest + + def analyze(self) -> dict: + """ + Return the analyze of the szs + :return: dictionnary of key and value of the analyze + """ + analyze = {} + for line in filter(lambda f: f.strip(), self._run("ANALYZE", self.path).decode().splitlines()): + key, value = line.split("=", 1) + analyze[key.strip()] = value.strip() + + return analyze + + def list_raw(self) -> list[str]: + """ + Return the list of subfiles + :return: the list of subfiles + """ + + # cycle though all of the output line of the command, check if the line are empty, and if not, + # add it to the list. Finally, remove the first line because this is a description of the command + return [subfile.strip() for subfile in self._run("list", self.path).decode().splitlines() if subfile][1:] + + def list(self) -> list["SZSSubPath"]: + """ + Return the list of subfiles + :return: the list of subfiles + """ + return [self.get_subfile(subfile) for subfile in self.list_raw()] + + def get_subfile(self, subfile: str) -> "SZSSubPath": + """ + Return the subfile of the szs + :return: the subfile + """ + return SZSSubPath(self, subfile) + + def __getitem__(self, item): + return self.get_subfile(item) + + def __iter__(self): + return iter(self.list()) + + +class SZSSubPath: + __slots__ = ("szs_path", "subfile") + + def __init__(self, szs_path: SZSPath, subfile: str): + self.szs_path = szs_path + self.subfile = subfile + + def __repr__(self): + return f"" + + def extract(self, dest: Path | str) -> Path: + """ + Extract the subfile to a destination + :param dest: destination path + :return: the extracted file path + """ + if self.is_dir(): raise ValueError("Can't extract a directory") + + dest: Path = dest if isinstance(dest, Path) else Path(dest) + if dest.is_dir(): dest /= self.basename() + with dest.open("wb") as file: - file.write(self.cat(subfile)) + file.write(self.szs_path.cat(self.subfile)) return dest + + def is_dir(self): + return self.subfile.endswith("/") + + def is_file(self): + return not self.is_dir() + + def basename(self): + return self.subfile.rsplit("/", 1)[-1]