diff --git a/vollerei/cli/__init__.py b/vollerei/cli/__init__.py new file mode 100644 index 0000000..50c9110 --- /dev/null +++ b/vollerei/cli/__init__.py @@ -0,0 +1,6 @@ +from typing import Any + + +class CLI: + def __init__(self): + pass diff --git a/vollerei/constants.py b/vollerei/constants.py index 2da7bd3..8ca8973 100644 --- a/vollerei/constants.py +++ b/vollerei/constants.py @@ -1,5 +1,3 @@ -from platformdirs import PlatformDirs - # Common telemetry_hosts = [ # Global @@ -14,13 +12,3 @@ telemetry_hosts = [ # HSR astra_repo = "https://notabug.org/mkrsym1/astra" jadeite_repo = "https://codeberg.org/mkrsym1/jadeite/" -hsr_latest_version = (1, 1, 0) - -base_dirs = PlatformDirs("vollerei", "tretrauit", roaming=True) -tools_data_path = base_dirs.site_data_path.joinpath("tools") -tools_cache_path = base_dirs.site_cache_path.joinpath("tools") -tools_cache_path.mkdir(parents=True, exist_ok=True) -launcher_cache_path = base_dirs.site_cache_path.joinpath("launcher") -launcher_cache_path.mkdir(parents=True, exist_ok=True) -utils_cache_path = base_dirs.site_cache_path.joinpath("utils") -utils_cache_path.mkdir(parents=True, exist_ok=True) diff --git a/vollerei/hsr/constants.py b/vollerei/hsr/constants.py new file mode 100644 index 0000000..d8d4f84 --- /dev/null +++ b/vollerei/hsr/constants.py @@ -0,0 +1,13 @@ +latest_version = (1, 1, 0) +md5sums = { + "1.0.5": { + "cn": { + "StarRailBase.dll": "66c42871ce82456967d004ccb2d7cf77", + "UnityPlayer.dll": "0c866c44bb3752031a8c12ffe935b26f", + }, + "os": { + "StarRailBase.dll": "8aa3790aafa3dd176678392f3f93f435", + "UnityPlayer.dll": "f17b9b7f9b8c9cbd211bdff7771a80c2", + }, + } +} diff --git a/vollerei/hsr/launcher/game.py b/vollerei/hsr/launcher/game.py index 8a67f22..14b0b9d 100644 --- a/vollerei/hsr/launcher/game.py +++ b/vollerei/hsr/launcher/game.py @@ -1,17 +1,44 @@ +from hashlib import md5 from os import PathLike from pathlib import Path +from enum import Enum from vollerei.abc.launcher.game import GameABC +from vollerei.hsr.constants import md5sums + + +class GameChannel(Enum): + Overseas = 0 + China = 1 class Game(GameABC): def __init__(self, path: PathLike = None): - self.path: Path | None = Path(path) if path else None + self._path: Path | None = Path(path) if path else None + + @property + def path(self) -> Path | None: + return self._path def is_installed(self) -> bool: - if self.path is None: + if self._path is None: return False if ( - not self.path.joinpath("StarRail.exe").exists() - or not self.path.joinpath("StarRailBase.dll").exists() + not self._path.joinpath("StarRail.exe").exists() + or not self._path.joinpath("StarRailBase.dll").exists() ): return False + + def get_channel(self) -> GameChannel: + if self.get_version() == (1, 0, 5): + for channel, v in md5sums["1.0.5"].values(): + for file, md5sum in v.values(): + if ( + md5(self._path.joinpath(file).read_bytes()).hexdigest() + != md5sum + ): + continue + match channel: + case "cn": + return GameChannel.China + case "os": + return GameChannel.Overseas diff --git a/vollerei/hsr/patcher.py b/vollerei/hsr/patcher.py index eda5dd3..25f967e 100644 --- a/vollerei/hsr/patcher.py +++ b/vollerei/hsr/patcher.py @@ -1,10 +1,14 @@ +from pathlib import Path +from enum import Enum +from shutil import copy2 +from distutils.version import StrictVersion from vollerei.abc.patcher import PatcherABC from vollerei.exceptions.game import GameNotInstalledError from vollerei.exceptions.patcher import VersionNotSupportedError -from vollerei.hsr.launcher.game import Game -from vollerei.utils.git import Git -from vollerei.constants import tools_data_path, astra_repo, jadeite_repo -from enum import Enum +from vollerei.hsr.launcher.game import Game, GameChannel +from vollerei.utils import download_and_extract, Git, Xdelta3 +from vollerei.paths import tools_data_path +from vollerei.constants import astra_repo, jadeite_repo class PatchType(Enum): @@ -23,7 +27,11 @@ class Patcher(PatcherABC): def __init__(self, patch_type: PatchType = PatchType.Jadeite): self._patch_type: PatchType = patch_type self._path = tools_data_path.joinpath("patcher") + self._path.mkdir(parents=True, exist_ok=True) + self._jadeite = self._path.joinpath("jadeite") + self._astra = self._path.joinpath("astra") self._git = Git() + self._xdelta3 = Xdelta3() @property def patch_type(self) -> PatchType: @@ -34,10 +42,21 @@ class Patcher(PatcherABC): self._patch_type = value def _update_astra(self): - self._git.pull_or_clone(astra_repo, self._path.joinpath("astra")) + self._git.pull_or_clone(astra_repo, self._astra) def _update_jadeite(self): - self._git.pull_or_clone(jadeite_repo, self._path.joinpath("jadeite")) + file = self._git.get_latest_release_dl(jadeite_repo)[0] + file_version = Path(file).stem[1:] + current_version = None + if self._jadeite.joinpath("version").exists(): + with open(self._jadeite.joinpath("version"), "r") as f: + current_version = f.read() + if current_version: + if StrictVersion(file_version) <= StrictVersion(current_version): + return + download_and_extract(file, self._jadeite) + with open(self._jadeite.joinpath("version"), "w") as f: + f.write(file_version) def update_patch(self): match self._patch_type: @@ -51,10 +70,54 @@ class Patcher(PatcherABC): raise VersionNotSupportedError( "Only version 1.0.5 is supported by Astra patch." ) + self._update_astra() + file_type = None + match game.get_channel(): + case GameChannel.China: + file_type = "cn" + case GameChannel.Overseas: + file_type = "os" + # Backup + for file in ["UnityPlayer.dll", "StarRailBase.dll"]: + game.path.joinpath(file).rename(game.path.joinpath(f"{file}.bak")) + # Patch + for file in ["UnityPlayer.dll", "StarRailBase.dll"]: + self._xdelta3.patch_file( + self._astra.joinpath(f"{file_type}/diffs/{file}.vcdiff"), + game.path.joinpath(f"{file}.bak"), + game.path.joinpath(file), + ) + # Copy files + for file in self._astra.joinpath(f"{file_type}/files/").rglob("*"): + if file.suffix == ".bat": + continue + if file.is_dir(): + game.path.joinpath( + file.relative_to(self._astra.joinpath(f"{file_type}/files/")) + ).mkdir(parents=True, exist_ok=True) + copy2( + file, + game.path.joinpath( + file.relative_to(self._astra.joinpath(f"{file_type}/files/")) + ), + ) - def _patch_jadeite(self, game: Game): - pass + def _patch_jadeite(self): + """ + "Patch" the game with Jadeite patch. + + Unlike Astra patch, Jadeite patch does not modify the game files directly + but uses DLLs to patch the game in memory and it has an injector to do that + automatically. + """ + self._update_jadeite() + return self._jadeite def patch_game(self, game: Game): if not game.is_installed(): raise GameNotInstalledError("Game is not installed") + match self._patch_type: + case PatchType.Astra: + self._patch_astra(game) + case PatchType.Jadeite: + return self._patch_jadeite() diff --git a/vollerei/paths.py b/vollerei/paths.py new file mode 100644 index 0000000..18180a9 --- /dev/null +++ b/vollerei/paths.py @@ -0,0 +1,21 @@ +from pathlib import Path +from platformdirs import PlatformDirs + + +base_paths = PlatformDirs("vollerei", "tretrauit", roaming=True) +tools_data_path: Path = None +tools_cache_path: Path = None +launcher_cache_path: Path = None +utils_cache_path: Path = None + + +def init_paths(): + global tools_data_path, tools_cache_path, launcher_cache_path, utils_cache_path + tools_data_path = base_paths.site_data_path.joinpath("tools") + tools_cache_path = base_paths.site_cache_path.joinpath("tools") + launcher_cache_path = base_paths.site_cache_path.joinpath("launcher") + utils_cache_path = base_paths.site_cache_path.joinpath("utils") + tools_data_path.mkdir(parents=True, exist_ok=True) + tools_cache_path.mkdir(parents=True, exist_ok=True) + launcher_cache_path.mkdir(parents=True, exist_ok=True) + utils_cache_path.mkdir(parents=True, exist_ok=True) diff --git a/vollerei/utils/__init__.py b/vollerei/utils/__init__.py new file mode 100644 index 0000000..339057c --- /dev/null +++ b/vollerei/utils/__init__.py @@ -0,0 +1,22 @@ +import requests +from zipfile import ZipFile +from io import BytesIO +from pathlib import Path + +# Re-exports +from vollerei.utils.git import Git +from vollerei.utils.xdelta3 import Xdelta3 + + +__all__ = ["Git", "Xdelta3", "download_and_extract"] + + +def download_and_extract(url: str, path: Path) -> None: + rsp = requests.get(url, stream=True) + rsp.raise_for_status() + with BytesIO() as f: + for chunk in rsp.iter_content(chunk_size=32768): + f.write(chunk) + f.seek(0) + with ZipFile(f) as z: + z.extractall(path) diff --git a/vollerei/utils/git/__init__.py b/vollerei/utils/git/__init__.py index 1ff1563..ea280f7 100644 --- a/vollerei/utils/git/__init__.py +++ b/vollerei/utils/git/__init__.py @@ -2,12 +2,11 @@ import subprocess import requests import json from pathlib import Path -from zipfile import ZipFile -from io import BytesIO from shutil import which, rmtree from urllib.parse import urlparse from vollerei.constants import utils_cache_path from vollerei.utils.git.exceptions import GitCloneError +from vollerei.utils import download_and_extract class Git: @@ -62,14 +61,7 @@ class Git: return data[0]["sha"] def _download_and_extract_zip(self, url: str, path: Path) -> None: - rsp = requests.get(url, stream=True) - rsp.raise_for_status() - file = BytesIO() - with open(file, "wb") as f: - for chunk in rsp.iter_content(chunk_size=32768): - f.write(chunk) - zip_file = ZipFile(file) - zip_file.extractall(path) + download_and_extract(url, path) path.joinpath(".git/PLEASE_INSTALL_GIT").touch() def _clone(self, url: str, path: str = None) -> None: @@ -99,6 +91,23 @@ class Git: else: raise NotImplementedError + def get_latest_release_dl(self, url: str) -> list[str]: + dl = [] + if Path(url).suffix == ".git": + url = url[:-4] + url_info = urlparse(url) + netloc = url_info.netloc + if self._is_gitea(netloc): + rsp = requests.get( + f"https://{netloc}/api/v1/repos/{url_info.path}/releases/latest", + ) + rsp.raise_for_status() + data = rsp.json() + for asset in data["assets"]: + dl.append(asset["browser_download_url"]) + else: + raise NotImplementedError + def pull_or_clone(self, url: str, path: str = None) -> None: """ Pulls or clones a git repository