mirror of
https://github.com/Faraphel/Atlas-Install.git
synced 2025-07-02 02:38:30 +02:00
moved safe_eval from Track.py to safe_eval.py, allowed getattr with no function limitation, added track_formatting to mod_config.json to customize the track text format for the menu, the race and the filename. Added a Combobox on the install menu for the extension
This commit is contained in:
parent
31a28c3cf1
commit
70ade3dc67
11 changed files with 356 additions and 124 deletions
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "0.11",
|
"version": "v0.12",
|
||||||
"name": "Mario Kart Wii Faraphel",
|
"name": "Mario Kart Wii Faraphel",
|
||||||
"nickname": "MKWF",
|
"nickname": "MKWF",
|
||||||
"variant": "60",
|
"variant": "60",
|
||||||
|
@ -67,16 +67,17 @@
|
||||||
},
|
},
|
||||||
"tags_cups": ["Wii U", "3DS", "DS", "GCN", "GBA", "N64", "SNES", "MKT", "RMX", "DX", "GP"],
|
"tags_cups": ["Wii U", "3DS", "DS", "GCN", "GBA", "N64", "SNES", "MKT", "RMX", "DX", "GP"],
|
||||||
|
|
||||||
|
"track_formatting": {
|
||||||
|
"menu_name": "{{ ('\\c{YOR2}\\x'+hex(65296+getattr(track, 'score'))[2:]+'\\{off} ') if hasattr(track, 'score') else '' }}{{ (prefix+' ') if prefix else '' }}{{ getattr(track, 'name', '') }}{{ (' ('+suffix +')') if suffix else '' }}",
|
||||||
|
"race_name": "{{ getattr(track, 'name', '/') }}",
|
||||||
|
"file_name": "{{ getattr(track, 'sha1', '/') }}"
|
||||||
|
},
|
||||||
|
|
||||||
"default_track": {
|
"default_track": {
|
||||||
"music":"T32",
|
"music":"T32",
|
||||||
"special":"T32",
|
"special":"T32",
|
||||||
"author":"MrFluffy",
|
"author":"MrFluffy",
|
||||||
"since_version":"0.1",
|
"tags":[]
|
||||||
"sha1":"54a1621fef2b137adcbe20b6dd710b5bc5f981a1",
|
|
||||||
"version":"v1.1",
|
|
||||||
"tags":[
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"translation": {
|
"translation": {
|
||||||
"INSTALLER_TITLE": "MKWF-Install",
|
"INSTALLER_TITLE": "MKWF-Install",
|
||||||
"LANGUAGE_SELECTION": "Language",
|
"LANGUAGE_SELECTION": "Language",
|
||||||
"TRACK_CONFIGURATION": "Track Configuration",
|
"TRACK_FILTER": "Track Filters",
|
||||||
"ADVANCED_CONFIGURATION": "Advanced",
|
"ADVANCED_CONFIGURATION": "Advanced",
|
||||||
"HELP": "Help"
|
"HELP": "Help"
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"translation": {
|
"translation": {
|
||||||
"INSTALLER_TITLE": "MKWF-Install",
|
"INSTALLER_TITLE": "MKWF-Install",
|
||||||
"LANGUAGE_SELECTION": "Langue",
|
"LANGUAGE_SELECTION": "Langue",
|
||||||
"TRACK_CONFIGURATION": "Configuration des courses",
|
"TRACK_FILTER": "filtrer les courses",
|
||||||
"ADVANCED_CONFIGURATION": "Avancée",
|
"ADVANCED_CONFIGURATION": "Avancée",
|
||||||
"HELP": "Aide"
|
"HELP": "Aide"
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@ from source import event
|
||||||
from source import *
|
from source import *
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from source.wt.wit import Extension
|
||||||
|
|
||||||
|
|
||||||
class SourceGameError(Exception):
|
class SourceGameError(Exception):
|
||||||
def __init__(self, path: Path | str):
|
def __init__(self, path: Path | str):
|
||||||
|
@ -126,6 +128,13 @@ class Window(tkinter.Tk):
|
||||||
"""
|
"""
|
||||||
return self.destination_game.get_path()
|
return self.destination_game.get_path()
|
||||||
|
|
||||||
|
def get_output_type(self) -> Extension:
|
||||||
|
"""
|
||||||
|
Get the output type
|
||||||
|
:return: output type
|
||||||
|
"""
|
||||||
|
return self.destination_game.get_output_type()
|
||||||
|
|
||||||
|
|
||||||
# Menu bar
|
# Menu bar
|
||||||
class Menu(tkinter.Menu):
|
class Menu(tkinter.Menu):
|
||||||
|
@ -159,8 +168,8 @@ class Menu(tkinter.Menu):
|
||||||
def __init__(self, master: tkinter.Menu):
|
def __init__(self, master: tkinter.Menu):
|
||||||
super().__init__(master, tearoff=False)
|
super().__init__(master, tearoff=False)
|
||||||
|
|
||||||
master.add_cascade(label=_("TRACK_CONFIGURATION"), menu=self)
|
master.add_cascade(label=_("TRACK_FILTER"), menu=self)
|
||||||
self.add_command(label="Change configuration")
|
self.add_command(label="Change filter")
|
||||||
|
|
||||||
# Advanced menu
|
# Advanced menu
|
||||||
class Advanced(tkinter.Menu):
|
class Advanced(tkinter.Menu):
|
||||||
|
@ -209,6 +218,7 @@ class Menu(tkinter.Menu):
|
||||||
class SourceGame(ttk.LabelFrame):
|
class SourceGame(ttk.LabelFrame):
|
||||||
def __init__(self, master: tkinter.Tk):
|
def __init__(self, master: tkinter.Tk):
|
||||||
super().__init__(master, text="Original Game File")
|
super().__init__(master, text="Original Game File")
|
||||||
|
self.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
self.entry = ttk.Entry(self, width=50)
|
self.entry = ttk.Entry(self, width=50)
|
||||||
self.entry.grid(row=1, column=1, sticky="nsew")
|
self.entry.grid(row=1, column=1, sticky="nsew")
|
||||||
|
@ -268,12 +278,17 @@ class SourceGame(ttk.LabelFrame):
|
||||||
class DestinationGame(ttk.LabelFrame):
|
class DestinationGame(ttk.LabelFrame):
|
||||||
def __init__(self, master: tkinter.Tk):
|
def __init__(self, master: tkinter.Tk):
|
||||||
super().__init__(master, text="Game Directory Destination")
|
super().__init__(master, text="Game Directory Destination")
|
||||||
|
self.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
self.entry = ttk.Entry(self, width=50)
|
self.entry = ttk.Entry(self)
|
||||||
self.entry.grid(row=1, column=1, sticky="nsew")
|
self.entry.grid(row=1, column=1, sticky="nsew")
|
||||||
|
|
||||||
|
self.output_type = ttk.Combobox(self, width=5, values=[extension.name for extension in Extension])
|
||||||
|
self.output_type.set(Extension.WBFS.name)
|
||||||
|
self.output_type.grid(row=1, column=2, sticky="nsew")
|
||||||
|
|
||||||
self.button = ttk.Button(self, text="...", width=2, command=self.select)
|
self.button = ttk.Button(self, text="...", width=2, command=self.select)
|
||||||
self.button.grid(row=1, column=2, sticky="nsew")
|
self.button.grid(row=1, column=3, sticky="nsew")
|
||||||
|
|
||||||
def select(self) -> None:
|
def select(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -304,6 +319,13 @@ class DestinationGame(ttk.LabelFrame):
|
||||||
if not path.exists(): raise DestinationGameError(path)
|
if not path.exists(): raise DestinationGameError(path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def get_output_type(self) -> Extension:
|
||||||
|
"""
|
||||||
|
Get the output type
|
||||||
|
:return: the output type
|
||||||
|
"""
|
||||||
|
return Extension[self.output_type.get()]
|
||||||
|
|
||||||
def set_state(self, state: InstallerState) -> None:
|
def set_state(self, state: InstallerState) -> None:
|
||||||
"""
|
"""
|
||||||
Set the progress bar state when the installer change state
|
Set the progress bar state when the installer change state
|
||||||
|
@ -312,7 +334,9 @@ class DestinationGame(ttk.LabelFrame):
|
||||||
"""
|
"""
|
||||||
for child in self.winfo_children():
|
for child in self.winfo_children():
|
||||||
match state:
|
match state:
|
||||||
case InstallerState.IDLE: child.config(state="normal")
|
case InstallerState.IDLE:
|
||||||
|
if child == self.output_type: child.config(state="readonly")
|
||||||
|
else: child.config(state="normal")
|
||||||
case InstallerState.INSTALLING: child.config(state="disabled")
|
case InstallerState.INSTALLING: child.config(state="disabled")
|
||||||
|
|
||||||
|
|
||||||
|
@ -339,14 +363,20 @@ class ButtonInstall(ttk.Button):
|
||||||
messagebox.showerror(_("ERROR"), _("ERROR_INVALID_DESTINATION_GAME"))
|
messagebox.showerror(_("ERROR"), _("ERROR_INVALID_DESTINATION_GAME"))
|
||||||
return
|
return
|
||||||
|
|
||||||
# get space remaining on the C: drive
|
# if there is no more space on the installer drive, show a warning
|
||||||
if shutil.disk_usage(".").free < minimum_space_available:
|
if shutil.disk_usage(".").free < minimum_space_available:
|
||||||
if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")):
|
if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# if there is no more space on the destination drive, show a warning
|
||||||
|
elif shutil.disk_usage(destination_path).free < minimum_space_available:
|
||||||
|
if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")):
|
||||||
|
return
|
||||||
|
|
||||||
game = Game(source_path)
|
game = Game(source_path)
|
||||||
mod_config = self.master.get_mod_config()
|
mod_config = self.master.get_mod_config()
|
||||||
self.master.progress_function(game.install_mod(destination_path, mod_config))
|
output_type = self.master.get_output_type()
|
||||||
|
self.master.progress_function(game.install_mod(destination_path, mod_config, output_type))
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.master.set_state(InstallerState.IDLE)
|
self.master.set_state(InstallerState.IDLE)
|
||||||
|
|
|
@ -1,4 +1,33 @@
|
||||||
# class that represent a mario kart wii cup
|
# class that represent a mario kart wii cup
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
class Cup:
|
class Cup:
|
||||||
def __init__(self, track1: "Track" = None, track2: "Track" = None, track3: "Track" = None, track4: "Track" = None):
|
__slots__ = ["_tracks", "cup_id"]
|
||||||
self._tracks = [track1, track2, track3, track4]
|
_last_cup_id = 0
|
||||||
|
|
||||||
|
def __init__(self, tracks: list["Track | TrackGroup"], cup_id: str | None = None):
|
||||||
|
self._tracks = tracks[:4]
|
||||||
|
|
||||||
|
if cup_id is None:
|
||||||
|
cup_id = self.__class__._last_cup_id
|
||||||
|
self.__class__._last_cup_id += 1
|
||||||
|
|
||||||
|
self.cup_id = cup_id
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Cup id={self.cup_id} tracks={self._tracks}>"
|
||||||
|
|
||||||
|
def get_cup_icon(self) -> Image.Image:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_ctfile(self, mod_config: "ModConfig") -> str:
|
||||||
|
"""
|
||||||
|
Get the ctfile for this cup
|
||||||
|
:return: the ctfile
|
||||||
|
"""
|
||||||
|
ctfile = f'C "{self.cup_id}"\n'
|
||||||
|
for track in self._tracks: ctfile += track.get_ctfile(mod_config=mod_config)
|
||||||
|
ctfile += "\n"
|
||||||
|
|
||||||
|
return ctfile
|
||||||
|
|
|
@ -3,7 +3,7 @@ from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from source.mkw.ModConfig import ModConfig
|
from source.mkw.ModConfig import ModConfig
|
||||||
from source.wt.wit import WITPath, Region
|
from source.wt.wit import WITPath, Region, Extension
|
||||||
|
|
||||||
|
|
||||||
class Game:
|
class Game:
|
||||||
|
@ -24,7 +24,7 @@ class Game:
|
||||||
"""
|
"""
|
||||||
return not any(self.wit_path[f"./files/rel/lecode-{region.value}.bin"].exists() for region in Region)
|
return not any(self.wit_path[f"./files/rel/lecode-{region.value}.bin"].exists() for region in Region)
|
||||||
|
|
||||||
def extract(self, dest: Path | str) -> Generator[str, None, Path]:
|
def extract(self, dest: Path | str) -> Generator[dict, None, Path]:
|
||||||
"""
|
"""
|
||||||
Extract the game to the destination directory. If the game is a FST, just copy to the destination
|
Extract the game to the destination directory. If the game is a FST, just copy to the destination
|
||||||
:param dest: destination directory
|
:param dest: destination directory
|
||||||
|
@ -43,10 +43,13 @@ class Game:
|
||||||
except StopIteration as e:
|
except StopIteration as e:
|
||||||
return e.value
|
return e.value
|
||||||
|
|
||||||
def install_mod(self, dest: Path, mod_config: ModConfig) -> Generator[str, None, None]:
|
def install_mod(self, dest: Path, mod_config: ModConfig, output_type: Extension) -> Generator[dict, None, None]:
|
||||||
"""
|
"""
|
||||||
Patch the game with the mod
|
Patch the game with the mod
|
||||||
:dest: destination directory
|
:dest: destination directory
|
||||||
:mod_config: mod configuration
|
:mod_config: mod configuration
|
||||||
|
:output_type: type of the destination game
|
||||||
"""
|
"""
|
||||||
yield from self.extract(dest / f"{mod_config.nickname} {mod_config.version}")
|
# yield from self.extract(dest / f"{mod_config.nickname} {mod_config.version}")
|
||||||
|
print(mod_config.get_ctfile())
|
||||||
|
yield {}
|
||||||
|
|
|
@ -2,6 +2,7 @@ from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from source.mkw import Tag, Color
|
from source.mkw import Tag, Color
|
||||||
|
from source.mkw.Cup import Cup
|
||||||
from source.mkw.Track import Track
|
from source.mkw.Track import Track
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
@ -10,33 +11,42 @@ import json
|
||||||
class ModConfig:
|
class ModConfig:
|
||||||
__slots__ = ("name", "nickname", "variant", "region", "tags_prefix", "tags_suffix",
|
__slots__ = ("name", "nickname", "variant", "region", "tags_prefix", "tags_suffix",
|
||||||
"default_track", "_tracks", "version", "original_track_prefix", "swap_original_order",
|
"default_track", "_tracks", "version", "original_track_prefix", "swap_original_order",
|
||||||
"keep_original_track", "enable_random_cup", "tags_cups")
|
"keep_original_track", "enable_random_cup", "tags_cups", "track_formatting")
|
||||||
|
|
||||||
def __init__(self, name: str, nickname: str = None, version: str = None, variant: str = None,
|
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,
|
tags_prefix: dict[Tag, Color] = None, tags_suffix: dict[Tag, Color] = None,
|
||||||
tags_cups: list[Tag] = None, region: dict[int] | int = None,
|
tags_cups: list[Tag] = None, region: dict[int] | int = None,
|
||||||
default_track: "Track | TrackGroup" = None, tracks: list["Track | TrackGroup"] = None,
|
default_track: "Track | TrackGroup" = None, tracks: list["Track | TrackGroup"] = None,
|
||||||
original_track_prefix: bool = None, swap_original_order: bool = None,
|
original_track_prefix: bool = None, swap_original_order: bool = None,
|
||||||
keep_original_track: bool = None, enable_random_cup: bool = None):
|
keep_original_track: bool = None, enable_random_cup: bool = None,
|
||||||
|
track_formatting: dict[str, str] = None):
|
||||||
|
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.nickname: str = nickname if nickname is not None else 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.version: str = version if version is not None else "v1.0.0"
|
||||||
self.variant: str = variant if variant is not None else "01"
|
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.region: dict[int] | int = region if region is not None else 0
|
||||||
|
|
||||||
self.tags_prefix: dict[Tag] = tags_prefix if tags_prefix is not None else {}
|
self.tags_prefix: dict[Tag] = tags_prefix if tags_prefix is not None else {}
|
||||||
self.tags_suffix: dict[Tag] = tags_suffix if tags_suffix is not None else {}
|
self.tags_suffix: dict[Tag] = tags_suffix if tags_suffix is not None else {}
|
||||||
self.tags_cups: dict[Tag] = tags_cups if tags_cups is not None else {}
|
self.tags_cups: list[Tag] = tags_cups if tags_cups is not None else []
|
||||||
|
|
||||||
self.default_track: "Track | TrackGroup" = default_track if default_track is not None else None
|
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._tracks: list["Track | TrackGroup"] = tracks if tracks is not None else []
|
||||||
|
self.track_formatting: dict[str, str] = {
|
||||||
|
"menu_name": "{{ getattr(track, 'name', '/') }}",
|
||||||
|
"race_name": "{{ getattr(track, 'name', '/') }}",
|
||||||
|
"file_name": "{{ getattr(track, 'sha1', '/') }}"
|
||||||
|
} | (track_formatting if track_formatting is not None else {})
|
||||||
|
|
||||||
self.original_track_prefix: bool = original_track_prefix if original_track_prefix is not None else True
|
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.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.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
|
self.enable_random_cup: bool = enable_random_cup if enable_random_cup is not None else True
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ModConfig name={self.name} version={self.version}>"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, config_dict: dict) -> "ModConfig":
|
def from_dict(cls, config_dict: dict) -> "ModConfig":
|
||||||
"""
|
"""
|
||||||
|
@ -44,11 +54,11 @@ class ModConfig:
|
||||||
:param config_dict: dict containing the configuration
|
:param config_dict: dict containing the configuration
|
||||||
:return: ModConfig
|
:return: ModConfig
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kwargs = {
|
kwargs = {
|
||||||
attr: config_dict.get(attr)
|
attr: config_dict.get(attr)
|
||||||
for attr in ["nickname", "version", "variant", "tags_prefix", "tags_suffix", "tags_cups",
|
for attr in cls.__slots__
|
||||||
"original_track_prefix", "swap_original_order", "keep_original_track", "enable_random_cup"]
|
if attr not in ["name", "default_track", "_tracks", "tracks"]
|
||||||
|
# these keys are treated after or are reserved
|
||||||
}
|
}
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
|
@ -78,3 +88,82 @@ class ModConfig:
|
||||||
for track in self._tracks:
|
for track in self._tracks:
|
||||||
yield from track.get_tracks()
|
yield from track.get_tracks()
|
||||||
|
|
||||||
|
def get_ordered_cups(self) -> Generator["Cup", None, None]:
|
||||||
|
"""
|
||||||
|
Get all the cups with cup tags
|
||||||
|
:return: cups with cup tags
|
||||||
|
"""
|
||||||
|
# use self._tracks instead of self._get_tracks() because we want the TrackGroup
|
||||||
|
# for track that have a tag in self.tags_cups
|
||||||
|
for tag_cup in self.tags_cups:
|
||||||
|
track_buffer: "Track | TrackGroup" = []
|
||||||
|
current_tag_name, current_tag_count = tag_cup, 0
|
||||||
|
|
||||||
|
# every four 4 tracks, create a cup
|
||||||
|
for track in filter(lambda track: tag_cup in getattr(track, "tags", []), self._tracks):
|
||||||
|
track_buffer.append(track)
|
||||||
|
|
||||||
|
if len(track_buffer) > 4:
|
||||||
|
current_tag_count += 1
|
||||||
|
yield Cup(tracks=track_buffer, cup_id=f"{current_tag_name}-{current_tag_count}")
|
||||||
|
track_buffer = []
|
||||||
|
|
||||||
|
# if there is still tracks in the buffer, create a cup with them and fill with default>
|
||||||
|
if len(track_buffer) > 0:
|
||||||
|
track_buffer.extend([self.default_track] * (4 - len(track_buffer)))
|
||||||
|
yield Cup(tracks=track_buffer, cup_id=f"{current_tag_name}-{current_tag_count+1}")
|
||||||
|
|
||||||
|
def get_unordered_cups(self) -> Generator["Cup", None, None]:
|
||||||
|
"""
|
||||||
|
Get all the cups with no cup tags
|
||||||
|
:return: cups with no cup tags
|
||||||
|
"""
|
||||||
|
# for track that have don't have a tag in self.tags_cups
|
||||||
|
track_buffer: "Track | TrackGroup" = []
|
||||||
|
for track in filter(
|
||||||
|
lambda track: not any(item in getattr(track, "tags", []) for item in self.tags_cups),
|
||||||
|
self._tracks
|
||||||
|
):
|
||||||
|
track_buffer.append(track)
|
||||||
|
|
||||||
|
if len(track_buffer) > 4:
|
||||||
|
yield Cup(tracks=track_buffer)
|
||||||
|
track_buffer = []
|
||||||
|
|
||||||
|
# if there is still tracks in the buffer, create a cup with them and fill with default
|
||||||
|
if len(track_buffer) > 0:
|
||||||
|
track_buffer.extend([self.default_track] * (4 - len(track_buffer)))
|
||||||
|
yield Cup(tracks=track_buffer)
|
||||||
|
|
||||||
|
def get_cups(self) -> Generator["Cup", None, None]:
|
||||||
|
"""
|
||||||
|
Get all the cups
|
||||||
|
:return: cups
|
||||||
|
"""
|
||||||
|
yield from self.get_ordered_cups()
|
||||||
|
yield from self.get_unordered_cups()
|
||||||
|
|
||||||
|
def get_ctfile(self) -> str:
|
||||||
|
"""
|
||||||
|
Return the ct_file generated from the ModConfig
|
||||||
|
:return: ctfile content
|
||||||
|
"""
|
||||||
|
lecode_flags = filter(lambda v: v is not None, [
|
||||||
|
"N$SHOW" if self.keep_original_track else "N$NONE",
|
||||||
|
"N$F_WII" if self.original_track_prefix else None,
|
||||||
|
"N$SWAP" if self.swap_original_order else None
|
||||||
|
])
|
||||||
|
|
||||||
|
ctfile = (
|
||||||
|
f"#CT-CODE\n" # magic number
|
||||||
|
f"[RACING-TRACK-LIST]\n" # start of the track section
|
||||||
|
f"%LE-FLAGS=1\n" # enable lecode mode
|
||||||
|
f"%WIIMM-CUP={int(self.enable_random_cup)}\n" # enable random cup
|
||||||
|
f"N {' | '.join(lecode_flags)}\n" # other flags to disable default tracks, ...
|
||||||
|
f"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
for cup in self.get_cups():
|
||||||
|
ctfile += cup.get_ctfile(mod_config=self)
|
||||||
|
|
||||||
|
return ctfile
|
||||||
|
|
|
@ -2,30 +2,11 @@ from typing import Generator
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from source.mkw import Tag, Slot
|
from source.mkw import Tag, Slot
|
||||||
|
from source.safe_eval import safe_eval
|
||||||
|
|
||||||
TOKEN_START = "{{"
|
TOKEN_START = "{{"
|
||||||
TOKEN_END = "}}"
|
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
|
# representation of a custom track
|
||||||
class Track:
|
class Track:
|
||||||
|
@ -41,6 +22,9 @@ class Track:
|
||||||
if key.startswith("__"): continue
|
if key.startswith("__"): continue
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Track name={getattr(self, 'name', '/')} tags={getattr(self, 'tags', '/')}>"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, track_dict: dict) -> "Track | TrackGroup":
|
def from_dict(cls, track_dict: dict) -> "Track | TrackGroup":
|
||||||
"""
|
"""
|
||||||
|
@ -68,75 +52,24 @@ class Track:
|
||||||
:return: formatted representation of the track
|
:return: formatted representation of the track
|
||||||
"""
|
"""
|
||||||
|
|
||||||
token_map = common_token_map | { # replace the suffix and the prefix by the corresponding values
|
extra_token_map = { # replace the suffix and the prefix by the corresponding values
|
||||||
"prefix": self.get_prefix(mod_config, ""),
|
"prefix": f'{self.get_prefix(mod_config, "")!r}',
|
||||||
"suffix": self.get_prefix(mod_config, ""),
|
"suffix": f'{self.get_suffix(mod_config, "")!r}',
|
||||||
} | { # replace the track attribute by the corresponding values
|
"track": "track"
|
||||||
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:
|
def format_template(match: re.Match) -> str:
|
||||||
# get the token string without the brackets, then strip it
|
"""
|
||||||
process_token = match.group(1).strip()
|
when a token is found, replace it by the corresponding value
|
||||||
final_token: str = ""
|
:param match: match in the format
|
||||||
|
:return: corresponding value
|
||||||
def matched(match: re.Match | str | None, value: str = None) -> bool:
|
"""
|
||||||
"""
|
# get the token string without the brackets, then strip it. Also double antislash
|
||||||
check if token is matched, if yes, add it to the final token and remove it from the processing token
|
template = match.group(1).strip().replace("\\", "\\\\")
|
||||||
:param match: match object
|
return safe_eval(template, extra_token_map, {"track": self})
|
||||||
: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
|
# pass everything between TOKEN_START and TOKEN_END in the function
|
||||||
return re.sub(rf"{TOKEN_START}(.*?){TOKEN_END}", format_token, format)
|
return re.sub(rf"{TOKEN_START}(.*?){TOKEN_END}", format_template, format)
|
||||||
|
|
||||||
def get_prefix(self, mod_config: "ModConfig", default: any = None) -> any:
|
def get_prefix(self, mod_config: "ModConfig", default: any = None) -> any:
|
||||||
"""
|
"""
|
||||||
|
@ -145,8 +78,7 @@ class Track:
|
||||||
:param mod_config: mod configuration
|
:param mod_config: mod configuration
|
||||||
:return: formatted representation of the track prefix
|
:return: formatted representation of the track prefix
|
||||||
"""
|
"""
|
||||||
for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags):
|
for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags): return tag
|
||||||
return mod_config.tags_prefix[tag]
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def get_suffix(self, mod_config: "ModConfig", default: any = None) -> any:
|
def get_suffix(self, mod_config: "ModConfig", default: any = None) -> any:
|
||||||
|
@ -156,9 +88,29 @@ class Track:
|
||||||
:param mod_config: mod configuration
|
:param mod_config: mod configuration
|
||||||
:return: formatted representation of the track suffix
|
:return: formatted representation of the track suffix
|
||||||
"""
|
"""
|
||||||
for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags):
|
for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags): return tag
|
||||||
return mod_config.tags_suffix[tag]
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def get_highlight(self, mod_config: "ModConfig", default: any = None) -> any:
|
def is_highlight(self, mod_config: "ModConfig", default: any = None) -> bool:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def is_new(self, mod_config: "ModConfig", default: any = None) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_ctfile(self, mod_config: "ModConfig", hidden: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
return the ctfile of the track
|
||||||
|
:hidden: if the track is in a group
|
||||||
|
:return: ctfile
|
||||||
|
"""
|
||||||
|
# TODO: filename, info and - are not implemented
|
||||||
|
menu_name = f'{self.repr_format(mod_config=mod_config, format=mod_config.track_formatting["menu_name"])!r}'
|
||||||
|
file_name = f'{self.repr_format(mod_config=mod_config, format=mod_config.track_formatting["file_name"])!r}'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'{"H" if hidden else "T"} {self.music}; ' # track type
|
||||||
|
f'{self.special}; {(0x04 if hidden else 0) | (0x01 if self.is_new(mod_config, False) else 0):#04x}; ' # lecode flags
|
||||||
|
f'{file_name}; ' # filename
|
||||||
|
f'{menu_name}; ' # name of the track in the menu
|
||||||
|
f'{file_name}\n' # unique identifier for each track
|
||||||
|
)
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
|
from source.mkw import Tag
|
||||||
|
|
||||||
|
|
||||||
# representation of a group of tracks
|
|
||||||
class TrackGroup:
|
class TrackGroup:
|
||||||
def __init__(self, tracks: list["Track"] = None):
|
def __init__(self, tracks: list["Track"] = None, tags: list[Tag] = None, name: str = None):
|
||||||
self.tracks = tracks if tracks is not None else []
|
self.tracks = tracks if tracks is not None else []
|
||||||
|
self.tags = tags if tags is not None else []
|
||||||
|
self.name = name if name is not None else ""
|
||||||
|
|
||||||
def get_tracks(self) -> Generator["Track", None, None]:
|
def get_tracks(self) -> Generator["Track", None, None]:
|
||||||
"""
|
"""
|
||||||
|
@ -24,4 +27,19 @@ class TrackGroup:
|
||||||
from source.mkw.Track import Track
|
from source.mkw.Track import Track
|
||||||
|
|
||||||
if "group" not in group_dict: return Track.from_dict(group_dict)
|
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"]])
|
return cls(
|
||||||
|
tracks=[Track.from_dict(track) for track in group_dict["group"]],
|
||||||
|
tags=group_dict.get("tags"),
|
||||||
|
name=group_dict.get("name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_ctfile(self, mod_config: "ModConfig") -> str:
|
||||||
|
"""
|
||||||
|
return the ctfile of the track group
|
||||||
|
:return: ctfile
|
||||||
|
"""
|
||||||
|
ctfile = f'T T11; T11; 0x02; "-"; "info"; "-"\n'
|
||||||
|
for track in self.get_tracks():
|
||||||
|
ctfile += track.get_ctfile(mod_config=mod_config, hidden=True)
|
||||||
|
|
||||||
|
return ctfile
|
||||||
|
|
110
source/safe_eval.py
Normal file
110
source/safe_eval.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import re
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
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", "getattr"
|
||||||
|
]
|
||||||
|
} | { # 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 TemplateParsingError(Exception):
|
||||||
|
def __init__(self, token: str):
|
||||||
|
super().__init__(f"Invalid token while parsing track representation:\n{token}")
|
||||||
|
|
||||||
|
|
||||||
|
class SafeFunction:
|
||||||
|
@classmethod
|
||||||
|
def get_all_safe_methods(cls) -> dict[str, Callable]:
|
||||||
|
"""
|
||||||
|
get all the safe methods defined by the class
|
||||||
|
:return: all the safe methods defined by the class
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
method: getattr(cls, method)
|
||||||
|
for method in dir(cls)
|
||||||
|
if not method.startswith("__") and method not in ["get_all_safe_methods"]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getattr(obj: any, attr: str, default: any = None) -> any:
|
||||||
|
"""
|
||||||
|
Safe getattr, raise an error if the attribute is a function
|
||||||
|
:param obj: object to get the attribute from
|
||||||
|
:param attr: attribute name
|
||||||
|
:param default: default value if the attribute is not found
|
||||||
|
:return: the attribute value
|
||||||
|
"""
|
||||||
|
attr = getattr(obj, attr) if default is None else getattr(obj, attr, default)
|
||||||
|
if callable(attr): raise AttributeError(f"getattr can't be used for functions (tried: tr{attr})")
|
||||||
|
return attr
|
||||||
|
|
||||||
|
|
||||||
|
def safe_eval(template: str, extra_token_map: dict[str, str] = None, env: dict[str, any] = None) -> str:
|
||||||
|
"""
|
||||||
|
Evaluate the template and return the result in a safe way
|
||||||
|
:param extra_token_map: additionnal tokens to use in the template
|
||||||
|
:param env: variables to use when using eval
|
||||||
|
:param template: template to evaluate
|
||||||
|
"""
|
||||||
|
if extra_token_map is None: extra_token_map = {}
|
||||||
|
if env is None: env = {}
|
||||||
|
|
||||||
|
token_map: dict[str, str] = common_token_map | extra_token_map
|
||||||
|
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, template
|
||||||
|
|
||||||
|
# if there is no match or the string is empty, return False
|
||||||
|
if not match: return False
|
||||||
|
|
||||||
|
if isinstance(match, re.Match):
|
||||||
|
template_raw = template[match.end():]
|
||||||
|
value = match.group()
|
||||||
|
|
||||||
|
else:
|
||||||
|
if not template.startswith(match): return False
|
||||||
|
template_raw = template[len(match):]
|
||||||
|
|
||||||
|
template = template_raw.lstrip()
|
||||||
|
final_token += value + (len(template_raw) - len(template)) * " "
|
||||||
|
return True
|
||||||
|
|
||||||
|
while template: # 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'^(["\'])((\\{2})*|(.*?[^\\](\\{2})*))\1', template)):
|
||||||
|
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]+)?', template)):
|
||||||
|
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 TemplateParsingError(template)
|
||||||
|
|
||||||
|
# if final_token is set, eval final_token and return the result
|
||||||
|
if final_token: return str(eval(final_token, SafeFunction.get_all_safe_methods(), env))
|
||||||
|
else: return final_token
|
|
@ -13,9 +13,9 @@ class Extension(enum.Enum):
|
||||||
"""
|
"""
|
||||||
Enum for game extension
|
Enum for game extension
|
||||||
"""
|
"""
|
||||||
FST = ".dol"
|
|
||||||
WBFS = ".wbfs"
|
WBFS = ".wbfs"
|
||||||
ISO = ".iso"
|
ISO = ".iso"
|
||||||
|
FST = ".dol"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _missing_(cls, value: str) -> "Extension | None":
|
def _missing_(cls, value: str) -> "Extension | None":
|
||||||
|
|
Loading…
Reference in a new issue