Files
vollerei/vollerei/hsr/launcher/game.py
tretrauit 6f030e79ce feat(hsr): download & install updates
WIP WIP WIP, probably will crash at some point.
Also the code in paths isn't working, so i'm merging them to constants in the next commit
2024-01-02 15:27:40 +07:00

373 lines
14 KiB
Python

from hashlib import md5
from io import IOBase
from os import PathLike
from pathlib import Path
from vollerei.abc.launcher.game import GameABC
from vollerei.common import ConfigFile, functions
from vollerei.common.api import resource
from vollerei.exceptions.game import (
GameAlreadyUpdatedError,
GameNotInstalledError,
PreDownloadNotAvailable,
ScatteredFilesNotAvailableError,
)
from vollerei.hsr.constants import MD5SUMS
from vollerei.hsr.launcher.enums import GameChannel
from vollerei.hsr.launcher import api
from vollerei import paths
from vollerei.utils import download
class Game(GameABC):
"""
Manages the game installation
Since channel detection isn't implemented yet, most functions assume you're
using the overseas version of the game. You can override channel by setting
the property `channel_override` to the channel you want to use.
"""
def __init__(self, path: PathLike = None, cache_path: PathLike = None):
self._path: Path | None = Path(path) if path else None
if not cache_path:
cache_path = paths.cache_path
cache_path = Path(cache_path)
self._cache: Path = cache_path.joinpath("game/hsr/")
self._cache.mkdir(parents=True, exist_ok=True)
self._version_override: tuple[int, int, int] | None = None
self._channel_override: GameChannel | None = None
@property
def version_override(self) -> tuple[int, int, int] | None:
"""
Overrides the game version.
This can be useful if you want to override the version of the game
and additionally working around bugs.
"""
return self._version_override
@version_override.setter
def version_override(self, version: tuple[int, int, int] | str | None):
if isinstance(version, str):
version = tuple(int(i) for i in version.split("."))
self._version_override = version
@property
def channel_override(self) -> GameChannel | None:
"""
Overrides the game channel.
Because game channel detection isn't implemented yet, you may need
to use this for some functions to work.
This can be useful if you want to override the channel of the game
and additionally working around bugs.
"""
return self._channel_override
@channel_override.setter
def channel_override(self, channel: GameChannel | str | None):
if isinstance(channel, str):
channel = GameChannel[channel]
self._channel_override = channel
@property
def path(self) -> Path | None:
"""
Paths to the game folder.
"""
return self._path
@path.setter
def path(self, path: PathLike):
self._path = Path(path)
def data_folder(self) -> Path:
"""
Paths to the game data folder.
"""
try:
return self._path.joinpath("StarRail_Data")
except AttributeError:
raise GameNotInstalledError("Game path is not set.")
def is_installed(self) -> bool:
"""
Checks if the game is installed.
Returns:
bool: True if the game is installed, False otherwise.
"""
if self._path is None:
return False
if (
not self._path.joinpath("StarRail.exe").exists()
or not self._path.joinpath("StarRailBase.dll").exists()
or not self._path.joinpath("StarRail_Data").exists()
):
return False
if self.get_version() == (0, 0, 0):
return False
return True
def get_version_config(self) -> tuple[int, int, int]:
"""
Gets the current installed game version from config.ini.
Using this is not recommended, as only official launcher creates
and uses this file, instead you should use `get_version()`.
This returns (0, 0, 0) if the version could not be found.
Returns:
tuple[int, int, int]: Game version.
"""
cfg_file = self._path.joinpath("config.ini")
if not cfg_file.exists():
return (0, 0, 0)
cfg = ConfigFile(cfg_file)
if "General" not in cfg.sections():
return (0, 0, 0)
if "game_version" not in cfg["General"]:
return (0, 0, 0)
version_str = cfg["General"]["game_version"]
if version_str.count(".") != 2:
return (0, 0, 0)
try:
version = tuple(int(i) for i in version_str.split("."))
except Exception:
return (0, 0, 0)
return version
def set_version_config(self):
"""
Sets the current installed game version to config.ini.
This method is meant to keep compatibility with the official launcher only.
"""
cfg_file = self._path.joinpath("config.ini")
if not cfg_file.exists():
raise FileNotFoundError("config.ini not found.")
cfg = ConfigFile(cfg_file)
cfg.set("General", "game_version", self.get_version_str())
cfg.save()
def get_version(self) -> tuple[int, int, int]:
"""
Gets the current installed game version.
Credits to An Anime Team for the code that does the magic:
https://github.com/an-anime-team/anime-game-core/blob/main/src/games/star_rail/game.rs#L49
If the above method fails, it'll fallback to read the config.ini file
for the version, which is not recommended (as described in
`get_version_config()` docs)
This returns (0, 0, 0) if the version could not be found
(usually indicates the game is not installed), and in fact `is_installed()` uses
this method to check if the game is installed too.
Returns:
tuple[int, int, int]: The version as a tuple of integers.
"""
data_file = self.data_folder().joinpath("data.unity3d")
if not data_file.exists():
return (0, 0, 0)
def bytes_to_int(byte_array: list[bytes]) -> int:
bytes_as_int = int.from_bytes(byte_array, byteorder="big")
actual_int = bytes_as_int - 48 # 48 is the ASCII code for 0
return actual_int
allowed = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57]
version_bytes: list[list[bytes]] = [[], [], []]
version_ptr = 0
correct = True
try:
with self.data_folder().joinpath("data.unity3d").open("rb") as f:
f.seek(0x7D0) # 2000 in decimal
for byte in f.read(10000):
match byte:
case 0:
version_bytes = [[], [], []]
version_ptr = 0
correct = True
case 46:
version_ptr += 1
if version_ptr > 2:
correct = False
case 38:
if (
correct
and len(version_bytes[0]) > 0
and len(version_bytes[1]) > 0
and len(version_bytes[2]) > 0
):
return (
bytes_to_int(version_bytes[0]),
bytes_to_int(version_bytes[1]),
bytes_to_int(version_bytes[2]),
)
case _:
if correct and byte in allowed:
version_bytes[version_ptr].append(byte)
else:
correct = False
except Exception:
pass
# Fallback to config.ini
return self.get_version_config()
def get_version_str(self) -> str:
"""
Gets the current installed game version as a string.
Returns:
str: The version as a string.
"""
return ".".join(str(i) for i in self.get_version())
def get_channel(self) -> GameChannel:
"""
Gets the current game channel.
Only works for Star Rail version 1.0.5, other versions will return the
overridden channel or None if no channel is overridden.
This is not needed for game patching, since the patcher will automatically
detect the channel.
Returns:
GameChannel: The current game channel.
"""
version = self._version_override or self.get_version()
if 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
else:
# if self._path.joinpath("StarRail_Data").is_dir():
# return GameChannel.Overseas
# elif self._path.joinpath("StarRail_Data").exists():
# return GameChannel.China
# No reliable method there, so we'll just return the overridden channel or
# fallback to overseas.
return self._channel_override or GameChannel.Overseas
def get_remote_game(self, pre_download: bool) -> resource.Game:
channel = self._channel_override or self.get_channel()
if pre_download:
game = api.get_resource(channel=channel).pre_download_game
if not game:
raise PreDownloadNotAvailable("Pre-download version is not available.")
return game
return api.get_resource(channel=channel).game
def get_update(self, pre_download: bool = False) -> resource.Diff | None:
"""
Gets the current game update.
Returns a `Diff` object that contains the update information or
None if the game is not installed or already up-to-date.
"""
if not self.is_installed():
return None
version = (
".".join(x for x in self._version_override)
if self._version_override
else self.get_version_str()
)
for diff in self.get_remote_game(pre_download=pre_download).diffs:
if diff.version == version:
return diff
return None
def repair_file(self, file: PathLike, pre_download: bool = False) -> None:
"""
Repairs a game file.
Args:
file (PathLike): The file to repair.
"""
if not self.is_installed():
raise GameNotInstalledError("Game is not installed.")
file = Path(file)
if not file.is_relative_to(self._path):
raise ValueError("File is not in the game folder.")
game = self.get_remote_game(pre_download=pre_download)
if game.latest.decompressed_path is None:
raise ScatteredFilesNotAvailableError("Scattered files are not available.")
url = game.latest.decompressed_path + "/" + file.relative_to(self._path)
# Backup the file
file.rename(file.with_suffix(file.suffix + ".bak"))
try:
# Download the file
download(url, file.with_suffix(""))
except Exception:
# Restore the backup
file.rename(file.with_suffix(""))
raise
else:
# Delete the backup
file.unlink(missing_ok=True)
def apply_update_archive(
self, archive_file: PathLike | IOBase, auto_repair: bool = True
) -> None:
"""
Applies an update archive to the game, it can be the game update or a
voicepack update.
`archive_file` can be a path to the archive file or a file-like object,
like if you have very high amount of RAM and want to download the update
to memory instead of disk, this can be useful for you.
`auto_repair` is used to determine whether to repair the file if it's
broken. If it's set to False, then it'll raise an exception if the file
is broken.
Args:
archive_file (PathLike | IOBase): The archive file.
auto_repair (bool, optional): Whether to repair the file if it's broken.
"""
if not self.is_installed():
raise GameNotInstalledError("Game is not installed.")
if not isinstance(archive_file, IOBase):
archive_file = Path(archive_file)
# Hello hell again, dealing with HDiffPatch and all the things again.
functions.apply_update_archive(self, archive_file, auto_repair=auto_repair)
self.set_version_config()
def install_update(
self, update_info: resource.Diff = None, auto_repair: bool = True
):
"""
Installs an update from a `Diff` object.
You may want to download the update manually and pass it to
`apply_update_archive()` instead for better control.
Args:
update_info (Diff, optional): The update information. Defaults to None.
"""
if not self.is_installed():
raise GameNotInstalledError("Game is not installed.")
if not update_info:
update_info = self.get_update()
if not update_info or update_info.version == self.get_version_str():
raise GameAlreadyUpdatedError("Game is already updated.")
archive_file = self._cache.joinpath(update_info.name)
download(update_info.path, archive_file)
self.apply_update_archive(archive_file=archive_file, auto_repair=auto_repair)