mirror of
https://github.com/Faraphel/Atlas-Install.git
synced 2025-07-05 20:28:27 +02:00
added Patch prototype implementation (untested and unfinished)
This commit is contained in:
parent
b34833397d
commit
5481b75cbf
3 changed files with 446 additions and 67 deletions
|
@ -2205,7 +2205,7 @@
|
|||
"version":"v2.1",
|
||||
"family":1079,
|
||||
"tags":[
|
||||
|
||||
"DKR"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -2,8 +2,8 @@ from pathlib import Path
|
|||
from typing import Generator
|
||||
|
||||
from source.mkw.ModConfig import ModConfig
|
||||
from source.mkw.Patch import Patch
|
||||
from source.wt import szs
|
||||
import json
|
||||
|
||||
|
||||
class ExtractedGame:
|
||||
|
@ -31,70 +31,6 @@ class ExtractedGame:
|
|||
yield {"description": "Installing MyStuff directory...", "determinate": False}
|
||||
...
|
||||
|
||||
def install_file(self, mod_config: ModConfig, patch_directory: Path | str, subfile: Path | str) \
|
||||
-> Generator[dict, None, None]:
|
||||
"""
|
||||
Install a file into the game
|
||||
:param patch_directory: patch_directory where the subfile is located
|
||||
:param subfile: subfile to install
|
||||
:param mod_config: the mod to install
|
||||
"""
|
||||
subfile = Path(subfile)
|
||||
yield {"description": f"Patch {patch_directory.name}\nInstalling {subfile.name}...", "determinate": False}
|
||||
|
||||
'''
|
||||
configuration = {
|
||||
"base": "/files/test.json", # path a another json file to use as a base
|
||||
|
||||
"mode": "copy", # copy, replace, ignore, edit the subfile [default: copy]
|
||||
# if edit is set, use the file in the extracted game as a source
|
||||
# replace can't be used inside szs
|
||||
|
||||
"replace_regex": None, # regex expression to match the file on the game to replace
|
||||
"if": "True", # safe eval expression to check if the file should be installed
|
||||
|
||||
"operation": { # other operation for the file
|
||||
"img-generate": {
|
||||
# width, height, default color, ... can be determined from the base file
|
||||
"format": "RGB", # type of the image
|
||||
"layers": [
|
||||
{"type": "image", ...},
|
||||
{"type": "text", ...}
|
||||
]
|
||||
}
|
||||
|
||||
"tpl-encode": {"encoding": "TPL.RGB565"}, # encode an image to a tpl with the given format
|
||||
|
||||
"bmg-replace": {
|
||||
"mode": "regex" # regex or id
|
||||
"template": {
|
||||
"CWF": "{{ ONLINE_SERVICE }}", # regex type expression
|
||||
"0x203F": "{{ ONLINE_SERVICE }}" # id type expression
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
configuration = { # default configuration
|
||||
"mode": "copy",
|
||||
"if": "True",
|
||||
}
|
||||
configuration_path = subfile.with_suffix(subfile.suffix + ".json")
|
||||
if configuration_path.exists(): configuration |= json.loads(configuration_path.read_text(encoding="utf8"))
|
||||
|
||||
def install_patch(self, mod_config: ModConfig, patch_directory: Path | str) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Install a patch into the game
|
||||
:param mod_config: the mod to install
|
||||
:param patch_directory: directory containing the patch
|
||||
"""
|
||||
patch_directory = Path(patch_directory)
|
||||
yield {"description": f"Installing Patch {patch_directory.parent.name}...", "determinate": False}
|
||||
|
||||
for subfile in filter(lambda sf: sf.suffix == ".json", patch_directory.glob("*")):
|
||||
self.install_file(mod_config, patch_directory, subfile)
|
||||
|
||||
def install_all_patch(self, mod_config: ModConfig) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Install all patchs of the mod_config into the game
|
||||
|
@ -107,4 +43,4 @@ class ExtractedGame:
|
|||
# for all the subdirectory named "_PATCH", apply the patch
|
||||
for part_directory in mod_config.get_mod_directory().glob("[!_]*"):
|
||||
for patch_directory in part_directory.glob("_PATCH/"):
|
||||
self.install_patch(mod_config, patch_directory)
|
||||
yield from Patch(patch_directory).install(self)
|
||||
|
|
443
source/mkw/Patch.py
Normal file
443
source/mkw/Patch.py
Normal file
|
@ -0,0 +1,443 @@
|
|||
from pathlib import Path
|
||||
import json
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from abc import abstractmethod, ABC
|
||||
from typing import Generator, IO
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
class PathOutsidePatch(Exception):
|
||||
def __init__(self, forbidden_path: Path, allowed_range: Path):
|
||||
super().__init__(f"Error : path {forbidden_path} outside of allowed range {allowed_range}")
|
||||
|
||||
|
||||
class InvalidPatchMode(Exception):
|
||||
def __init__(self, mode: str):
|
||||
super().__init__(f"Error : mode \"{mode}\" is not implemented")
|
||||
|
||||
|
||||
class InvalidPatchOperation(Exception):
|
||||
def __init__(self, operation: str):
|
||||
super().__init__(f"Error : operation \"{operation}\" is not implemented")
|
||||
|
||||
|
||||
class InvalidImageLayerType(Exception):
|
||||
def __init__(self, layer_type: str):
|
||||
super().__init__(f"Error : layer type \"{layer_type}\" is not implemented")
|
||||
|
||||
|
||||
class Patch:
|
||||
"""
|
||||
Represent a patch object
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {self.path}>"
|
||||
|
||||
def install(self, extracted_game: "ExtractedGame") -> Generator[dict, None, None]:
|
||||
"""
|
||||
patch a game with this Patch
|
||||
"""
|
||||
# take all the files in the root directory, and patch them into the game.
|
||||
# Patch is not directly applied to the root to avoid custom configuration
|
||||
# on the base directory.
|
||||
for file in self.path.iterdir():
|
||||
pathname = file.relative_to(self.path)
|
||||
yield from PatchDirectory(self, str(pathname)).install(extracted_game, extracted_game.path / pathname)
|
||||
|
||||
|
||||
class PatchOperation:
|
||||
"""
|
||||
Represent an operation that can be applied onto a patch to modify it before installing
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_operation_by_name(cls, name: str):
|
||||
match name:
|
||||
case "img-generate":
|
||||
return cls.ImageGenerator
|
||||
case "tpl-encode":
|
||||
return cls.TplConverter
|
||||
case "bmg-replace":
|
||||
return cls.BmgEditor
|
||||
case _:
|
||||
raise InvalidPatchOperation(name)
|
||||
|
||||
class Operation(ABC):
|
||||
@abstractmethod
|
||||
def patch(self, patch: "Patch", file_name: str, file_content: IO) -> (str, IO):
|
||||
"""
|
||||
patch a file and return the new file_path (if changed) and the new content of the file
|
||||
"""
|
||||
|
||||
class ImageGenerator(Operation):
|
||||
"""
|
||||
generate a new image based on a file and apply a generator on it
|
||||
"""
|
||||
|
||||
def __init__(self, layers: list[dict]):
|
||||
self.layers: list["Layer"] = [self.Layer(layer) for layer in layers]
|
||||
|
||||
def patch(self, patch: "Patch", file_name: str, file_content: IO) -> (str, IO):
|
||||
image = Image.open(file_content)
|
||||
|
||||
for layer in self.layers:
|
||||
image = layer.patch_image(patch, image)
|
||||
|
||||
patch_content = BytesIO()
|
||||
image.save(patch_content, format="PNG")
|
||||
patch_content.seek(0)
|
||||
|
||||
return file_name, file_content
|
||||
|
||||
class Layer(ABC):
|
||||
"""
|
||||
represent a layer for a image generator
|
||||
"""
|
||||
|
||||
def __new__(cls, layer: dict):
|
||||
match layer["type"]:
|
||||
case "color":
|
||||
obj = ColorLayer
|
||||
case "image":
|
||||
obj = ImageLayer
|
||||
case "text":
|
||||
obj = TextLayer
|
||||
case _:
|
||||
raise InvalidImageLayerType(layer["type"])
|
||||
|
||||
return obj(**layer)
|
||||
|
||||
def get_bbox(self, image: Image.Image) -> tuple:
|
||||
"""
|
||||
return a tuple of a bbox from x1, x2, y1, y2
|
||||
if float, calculate the position like a percentage on the image
|
||||
if int, use directly the position
|
||||
"""
|
||||
if isinstance(x1 := self.x1, float): x1 = int(x1 * image.width)
|
||||
if isinstance(y1 := self.y1, float): y1 = int(y1 * image.height)
|
||||
if isinstance(x2 := self.x2, float): x2 = int(x2 * image.width)
|
||||
if isinstance(y2 := self.y2, float): y2 = int(y2 * image.height)
|
||||
|
||||
return x1, y1, x2, y2
|
||||
|
||||
def get_bbox_size(self, image: Image.Image) -> tuple:
|
||||
"""
|
||||
return the size that a layer use on the image
|
||||
"""
|
||||
x1, y1, x2, y2 = self.get_bbox(image)
|
||||
return x2 - x1, y2 - y1
|
||||
|
||||
def get_font_size(self, image: Image.Image) -> int:
|
||||
"""
|
||||
return the font_size of a layer
|
||||
"""
|
||||
return int(self.font_size * image.height) if isinstance(self.font_size, float) else self.font_size
|
||||
|
||||
def get_layer_position(self, image: Image.Image) -> tuple:
|
||||
"""
|
||||
return a tuple of the x and y position
|
||||
if x / y is a float, calculate the position like a percentage on the image
|
||||
if x / y is an int, use directly the position
|
||||
"""
|
||||
if isinstance(x := self.x, float): x = int(x * image.width)
|
||||
if isinstance(y := self.y, float): y = int(y * image.height)
|
||||
|
||||
return x, y
|
||||
|
||||
@abstractmethod
|
||||
def patch_image(self, patch: "Patch", image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Patch an image with the actual layer. Return the new image.
|
||||
"""
|
||||
|
||||
class ColorLayer(Layer):
|
||||
"""
|
||||
Represent a layer that fill a rectangle with a certain color on the image
|
||||
"""
|
||||
|
||||
def __init__(self, color: tuple[int] = (0,), x1: int | float = 0, y1: int | float = 0, x2: int | float = 1,
|
||||
y2: int | float = 1):
|
||||
self.x1: int = x1
|
||||
self.y1: int = y1
|
||||
self.x2: int = x2
|
||||
self.y2: int = y2
|
||||
self.color: tuple[int] = tuple(color)
|
||||
|
||||
def patch_image(self, patch: "Patch", image: Image.Image):
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle(self.get_bbox(image), self.color)
|
||||
|
||||
return image
|
||||
|
||||
class ImageLayer(Layer):
|
||||
"""
|
||||
Represent a layer that paste an image on the image
|
||||
"""
|
||||
|
||||
def __init__(self, image_path: str, x1: int | float = 0, y1: int | float = 0, x2: int | float = 1,
|
||||
y2: int | float = 1):
|
||||
self.x1: int = x1
|
||||
self.y1: int = y1
|
||||
self.x2: int = x2
|
||||
self.y2: int = y2
|
||||
self.image_path: str = image_path
|
||||
|
||||
def patch(self, patch: "Patch", image: Image.Image) -> Image.Image:
|
||||
# check if the path is outside of the allowed directory
|
||||
layer_image_path = patch.path / self.image_path
|
||||
if not layer_image_path.is_relative_to(patch.path): raise PathOutsidePatch(layer_image_path, patch.path)
|
||||
|
||||
layer_image = Image.open(layer_image_path).resize(self.get_bbox_size(image)).convert("RGBA")
|
||||
|
||||
image.paste(
|
||||
layer_image,
|
||||
box=self.get_bbox(image),
|
||||
mask=layer_image
|
||||
)
|
||||
|
||||
return image
|
||||
|
||||
class TextLayer:
|
||||
"""
|
||||
Represent a layer that write a text on the image
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, font_path: str | None = None, font_size: int = 10, color: tuple[int] = (255,),
|
||||
x: int | float = 0, y: int | float = 0):
|
||||
self.x: int = x
|
||||
self.y: int = y
|
||||
self.font_path: str | None = font_path
|
||||
self.font_size: int = font_size
|
||||
self.color: tuple[int] = tuple(color)
|
||||
self.text: str = text
|
||||
|
||||
def patch_image(self, patch: "Patch", image: Image.Image) -> Image.Image:
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
if self.font_path is not None:
|
||||
font_image_path = patch.path / self.font_path
|
||||
if not font_image_path.is_relative_to(patch.path):
|
||||
raise PathOutsidePatch(font_image_path, patch.path)
|
||||
else:
|
||||
font_image_path = None
|
||||
|
||||
font = ImageFont.truetype(font=font_image_path, size=self.get_font_size(image))
|
||||
draw.text(self.get_layer_position(layer), text=self.text, fill=self.color, font=font)
|
||||
|
||||
return image
|
||||
|
||||
class TplConverter(Operation):
|
||||
"""
|
||||
convert an image to a tpl file
|
||||
"""
|
||||
|
||||
def __init__(self): ...
|
||||
|
||||
def patch(self, patch: "Patch", file_name: str, file_content: IO) -> (str, IO): ...
|
||||
|
||||
class BmgEditor(Operation):
|
||||
"""
|
||||
edit a bmg
|
||||
"""
|
||||
|
||||
def __init__(self): ...
|
||||
|
||||
def patch(self, patch: "Patch", file_name: str, file_content: IO) -> (str, IO): ...
|
||||
|
||||
|
||||
class PatchObject:
|
||||
"""
|
||||
Represent an object inside a patch
|
||||
"""
|
||||
|
||||
def __init__(self, patch: Patch, subpath: str):
|
||||
self.patch = patch
|
||||
self.subpath = subpath
|
||||
self._configuration = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {self.full_path}>"
|
||||
|
||||
@property
|
||||
def full_path(self) -> Path:
|
||||
return self.patch.path / self.subpath
|
||||
|
||||
@property
|
||||
def configuration(self) -> dict:
|
||||
"""
|
||||
return the configuration from the file
|
||||
"""
|
||||
if self._configuration is not None: return self._configuration
|
||||
|
||||
# default configuration
|
||||
self._configuration = {
|
||||
"mode": "copy",
|
||||
"if": "True",
|
||||
}
|
||||
|
||||
configuration_path = self.full_path.with_suffix(self.full_path.suffix + ".json")
|
||||
if not configuration_path.exists(): return self._configuration
|
||||
|
||||
self._configuration |= json.loads(configuration_path.read_text(encoding="utf8"))
|
||||
|
||||
# if configuration inherit from an another file, then load it from the patch root,
|
||||
# keep this configuration keys over inherited one.
|
||||
# pop "base" to avoid infinite loop
|
||||
while "base" in self._configuration:
|
||||
self._configuration |= json.loads(
|
||||
(self.patch.path / self._configuration.pop("base")).read_text(encoding="utf8"))
|
||||
|
||||
return self._configuration
|
||||
|
||||
def subfile_from_path(self, path: Path) -> "PatchObject":
|
||||
"""
|
||||
return a PatchObject from a path
|
||||
"""
|
||||
obj = PatchDirectory if path.is_dir() else PatchFile
|
||||
return obj(self.patch, str(path.relative_to(self.patch.path)))
|
||||
|
||||
@abstractmethod
|
||||
def install(self, extracted_game: "ExtractedGame", game_subpath: Path) -> Generator[dict, None, None]:
|
||||
"""
|
||||
install the PatchObject into the game
|
||||
yield the step of the process
|
||||
"""
|
||||
|
||||
|
||||
class PatchFile(PatchObject):
|
||||
"""
|
||||
Represent a file from a patch
|
||||
"""
|
||||
|
||||
def get_source_path(self, game_subpath: Path):
|
||||
"""
|
||||
Return the path of the file that the patch is applied on.
|
||||
If the configuration mode is set to "edit", then return the path of the file inside the Game
|
||||
Else, return the path of the file inside the Patch
|
||||
"""
|
||||
match self.configuration["mode"]:
|
||||
case "edit":
|
||||
return game_subpath
|
||||
case _:
|
||||
return self.full_path
|
||||
|
||||
def install(self, extracted_game: "ExtractedGame", game_subpath: Path) -> Generator[dict, None, None]:
|
||||
"""
|
||||
patch a subfile of the game with the PatchFile
|
||||
"""
|
||||
yield {"description": f"Patching {self}"}
|
||||
|
||||
if self.full_path.name.startswith("#"):
|
||||
print(f"special file : {self} [install to {game_subpath}]")
|
||||
return
|
||||
|
||||
if not safe_eval(self.configuration["if"]): return
|
||||
|
||||
# apply operation on the file
|
||||
patch_source: Path = self.get_source_path(game_subpath)
|
||||
patch_name: str = game_subpath.name
|
||||
patch_content: IO = open(patch_source, "rb")
|
||||
|
||||
for operation_name, operation in self.configuration.get("operation", {}).items():
|
||||
# process every operation and get the new patch_path (if the name is changed)
|
||||
# and the new content of the patch
|
||||
patch_name, patch_content = PatchOperation.get_operation_by_name(operation_name)(*operation).patch(
|
||||
self.patch, patch_name, patch_content
|
||||
)
|
||||
|
||||
match self.configuration["mode"]:
|
||||
# if the mode is copy, replace the subfile in the game by the PatchFile
|
||||
case "copy" | "edit":
|
||||
print(f"[copy] copying {self} to {game_subpath}")
|
||||
|
||||
with open(game_subpath.parent / patch_name, "wb") as file:
|
||||
file.write(patch_content.read())
|
||||
|
||||
# if the mode is match, replace all the subfiles that match match_regex by the PatchFile
|
||||
case "match":
|
||||
for game_subfile in game_subpath.parent.glob(self.configuration["match_regex"]):
|
||||
# disallow patching files outside of the game
|
||||
if not game_subfile.relative_to(extracted_game.path):
|
||||
raise PathOutsidePatch(game_subfile, extracted_game.path)
|
||||
# patch the game with the subpatch
|
||||
print(f"[match] copying {self} to {game_subfile}")
|
||||
|
||||
with open(game_subfile.parent / patch_name, "wb") as file:
|
||||
file.write(patch_content.read())
|
||||
|
||||
# else raise an error
|
||||
case _:
|
||||
raise InvalidPatchMode(self.configuration["mode"])
|
||||
|
||||
patch_content.close()
|
||||
|
||||
|
||||
class PatchDirectory(PatchObject):
|
||||
"""
|
||||
Represent a directory from a patch
|
||||
"""
|
||||
|
||||
@property
|
||||
def subpatchs(self) -> Generator["PatchObject", None, None]:
|
||||
"""
|
||||
return all the subpatchs inside the PatchDirectory
|
||||
"""
|
||||
for subpath in self.full_path.iterdir():
|
||||
if subpath.suffix == ".json": continue
|
||||
yield self.subfile_from_path(subpath)
|
||||
|
||||
def install(self, extracted_game: "ExtractedGame", game_subpath: Path) -> Generator[dict, None, None]:
|
||||
"""
|
||||
patch a subdirectory of the game with the PatchDirectory
|
||||
"""
|
||||
yield {"description": f"Patching {self}"}
|
||||
|
||||
if not safe_eval(self.configuration["if"]): return
|
||||
|
||||
match self.configuration["mode"]:
|
||||
# if the mode is copy, then simply patch the subfile into the game with the same path
|
||||
case "copy":
|
||||
for subpatch in self.subpatchs:
|
||||
# install the subfile in the directory with the same name as the PatchDirectory
|
||||
yield from subpatch.install(extracted_game, game_subpath / subpatch.full_path.name)
|
||||
|
||||
# if the mode is replace, patch all the files that match the replace_regex
|
||||
case "match":
|
||||
for subpatch in self.subpatchs:
|
||||
# install the subfile in all the directory that match the match_regex of the configuration
|
||||
for game_subfile in game_subpath.parent.glob(self.configuration["match_regex"]):
|
||||
# disallow patching files outside of the game
|
||||
if not game_subfile.relative_to(extracted_game.path):
|
||||
raise PathOutsidePatch(game_subfile, extracted_game.path)
|
||||
# patch the game with the subpatch
|
||||
yield from subpatch.install(extracted_game, game_subfile / subpatch.full_path.name)
|
||||
|
||||
# else raise an error
|
||||
case _:
|
||||
raise InvalidPatchMode(self.configuration["mode"])
|
||||
|
||||
|
||||
# TODO : extract SZS
|
||||
# TODO : implement TPL
|
||||
# TODO : implement BMG
|
||||
# TODO : safe_eval
|
||||
|
||||
|
||||
configuration_example = {
|
||||
"operation": { # other operation for the file
|
||||
"tpl-encode": {"encoding": "TPL.RGB565"}, # encode an image to a tpl with the given format
|
||||
|
||||
"bmg-replace": {
|
||||
"mode": "regex", # regex or id
|
||||
"template": {
|
||||
"CWF": "{{ ONLINE_SERVICE }}", # regex type expression
|
||||
"0x203F": "{{ ONLINE_SERVICE }}" # id type expression
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue