diff --git a/README.md b/README.md
index 82f4a62..b0ad5f9 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ since I want to support other anime games and the launcher code is very messy, t
- [x] Download the game update (including pre-downloads if available)
- [x] Get the game version
- [x] Get installed voicepacks
-- [ ] Installation
+- [x] Installation
- [x] Patch the game for unsupported platforms (with telemetry checking)
- [x] Repair the game (Smarter than the official launcher!)
- [x] Update the game
diff --git a/vollerei/cli/hsr.py b/vollerei/cli/hsr.py
index 34070f5..d1205b4 100644
--- a/vollerei/cli/hsr.py
+++ b/vollerei/cli/hsr.py
@@ -80,6 +80,23 @@ def callback(
command.add_style("warn", fg="yellow")
+def set_version_config(self: Command):
+ self.line("Setting version config... ")
+ try:
+ State.game.set_version_config()
+ except Exception as e:
+ self.line_error(f"Couldn't set version config: {e}")
+ self.line_error(
+ "This won't affect the overall experience, but if you're using the official launcher"
+ )
+ self.line_error(
+ "you may have to edit the file 'config.ini' manually to reflect the latest version."
+ )
+ self.line(
+ f"The game has been updated to version: {State.game.get_version_str()}"
+ )
+
+
class VoicepackListInstalled(Command):
name = "hsr voicepack list-installed"
description = "Get the installed voicepacks"
@@ -198,8 +215,7 @@ class VoicepackUpdateAll(Command):
progress.finish(
f"Update applied for language {remote_voicepack.language.name}."
)
- self.line("Setting version config... ")
- State.game.set_version_config()
+ set_version_config(self=self)
self.line(
f"The game has been updated to version: {State.game.get_version_str()}"
)
@@ -407,6 +423,72 @@ class GetVersionCommand(Command):
self.line_error(f"Couldn't get game version: {e}")
+class InstallCommand(Command):
+ name = "hsr install"
+ description = (
+ "Installs the latest version of the game to the specified path (default: current directory). "
+ + "Note that this will not install the default voicepack (English), you need to install it manually."
+ )
+ options = default_options + [
+ option("pre-download", description="Pre-download the game if available"),
+ ]
+
+ def handle(self):
+ callback(command=self)
+ pre_download = self.option("pre-download")
+ progress = utils.ProgressIndicator(self)
+ progress.start("Fetching install package information... ")
+ try:
+ game_info = State.game.get_remote_game(pre_download=pre_download)
+ except Exception as e:
+ progress.finish(
+ f"Fetching failed with following error: {e} \n{traceback.format_exc()}"
+ )
+ return
+ progress.finish(
+ "Installation information fetched successfully."
+ )
+ if not self.confirm("Do you want to install the game?"):
+ self.line("Installation aborted.")
+ return
+ self.line("Downloading install package...")
+ first_pkg_out_path = None
+ for game_pkg in game_info.major.game_pkgs:
+ out_path = State.game.cache.joinpath(PurePath(game_pkg.url).name)
+ if not first_pkg_out_path:
+ first_pkg_out_path = out_path
+ try:
+ download_result = utils.download(
+ game_pkg.url, out_path, file_len=game_pkg.size
+ )
+ except Exception as e:
+ self.line_error(
+ f"Couldn't download install package: {e}"
+ )
+ return
+ if not download_result:
+ self.line_error("Download failed.")
+ return
+ self.line("Download completed.")
+ progress = utils.ProgressIndicator(self)
+ progress.start("Installing package...")
+ try:
+ State.game.install_archive(first_pkg_out_path)
+ except Exception as e:
+ progress.finish(
+ f"Couldn't install package: {e} \n{traceback.format_exc()}"
+ )
+ return
+ progress.finish("Package applied for the base game.")
+ self.line("Setting version config... ")
+ State.game.version_override = game_info.major.version
+ set_version_config()
+ State.game.version_override = None
+ self.line(
+ f"The game has been installed to version: {State.game.get_version_str()}"
+ )
+
+
class UpdateCommand(Command):
name = "hsr update"
description = "Updates the local game if available"
@@ -517,8 +599,9 @@ class UpdateCommand(Command):
f"Update applied for language {remote_voicepack.language.name}."
)
self.line("Setting version config... ")
+ State.game.version_override = game_info.major.version
+ set_version_config()
State.game.version_override = None
- State.game.set_version_config()
self.line(
f"The game has been updated to version: {State.game.get_version_str()}"
)
@@ -643,6 +726,28 @@ class UpdateDownloadCommand(Command):
self.line("Download completed.")
+class ApplyInstallArchive(Command):
+ name = "hsr install apply-archive"
+ description = "Applies the install archive"
+ arguments = [argument("path", description="Path to the install archive")]
+ options = default_options
+
+ def handle(self):
+ callback(command=self)
+ install_archive = self.argument("path")
+ progress = utils.ProgressIndicator(self)
+ progress.start("Applying install package...")
+ try:
+ State.game.install_archive(install_archive)
+ except Exception as e:
+ progress.finish(
+ f"Couldn't apply package: {e} \n{traceback.format_exc()}"
+ )
+ return
+ progress.finish("Package applied.")
+ set_version_config(self=self)
+
+
class ApplyUpdateArchive(Command):
name = "hsr update apply-archive"
description = "Applies the update archive to the local game"
@@ -655,10 +760,8 @@ class ApplyUpdateArchive(Command):
def handle(self):
callback(command=self)
- auto_repair = self.option("auto-repair")
update_archive = self.argument("path")
- if auto_repair:
- self.line("Auto-repair is enabled.")
+ auto_repair = self.option("auto-repair")
progress = utils.ProgressIndicator(self)
progress.start("Applying update package...")
try:
@@ -669,25 +772,14 @@ class ApplyUpdateArchive(Command):
)
return
progress.finish("Update applied.")
- self.line("Setting version config... ")
- try:
- State.game.set_version_config()
- except Exception as e:
- self.line_error(f"Couldn't set version config: {e}")
- self.line_error(
- "This won't affect the overall experience, but if you're using the official launcher"
- )
- self.line_error(
- "you may have to edit the file 'config.ini' manually to reflect the latest version."
- )
- self.line(
- f"The game has been updated to version: {State.game.get_version_str()}"
- )
+ set_version_config()
commands = [
+ ApplyInstallArchive,
ApplyUpdateArchive,
GetVersionCommand,
+ InstallCommand,
PatchCommand,
PatchInstallCommand,
PatchTelemetryCommand,
diff --git a/vollerei/common/functions.py b/vollerei/common/functions.py
index 86f1261..39ce34f 100644
--- a/vollerei/common/functions.py
+++ b/vollerei/common/functions.py
@@ -10,6 +10,7 @@ from vollerei.abc.launcher.game import GameABC
from vollerei.common.api import resource
from vollerei.exceptions.game import (
RepairError,
+ GameAlreadyInstalledError,
GameNotInstalledError,
ScatteredFilesNotAvailableError,
)
@@ -146,6 +147,23 @@ def apply_update_archive(
archive.close()
+def install_archive(game: GameABC, archive_file: Path | IOBase) -> None:
+ """
+ Applies an install archive to the game, it can be the game itself or a
+ voicepack one.
+
+ Because this function is shared for all games, you should use the game's
+ `install_archive()` method instead, which additionally applies required
+ methods for that game.
+ """
+ if game.is_installed():
+ raise GameAlreadyInstalledError("Game is already installed.")
+ # It's literally 3 lines but okay
+ archive = py7zr.SevenZipFile(archive_file, "r")
+ archive.extractall(game.path)
+ archive.close()
+
+
def _repair_file(game: GameABC, file: PathLike, game_info: resource.Main) -> None:
# .replace("\\", "/") is needed because Windows uses backslashes :)
relative_file = file.relative_to(game.path)
diff --git a/vollerei/hsr/launcher/game.py b/vollerei/hsr/launcher/game.py
index 7defe07..b776832 100644
--- a/vollerei/hsr/launcher/game.py
+++ b/vollerei/hsr/launcher/game.py
@@ -401,6 +401,22 @@ class Game(GameABC):
"""
functions.repair_game(self)
+ def install_archive(self, archive_file: PathLike | IOBase) -> None:
+ """
+ Applies an install archive to the game, it can be the game itself or a
+ voicepack one.
+
+ `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 archive
+ to memory instead of disk, this can be useful for you.
+
+ Args:
+ archive_file (PathLike | IOBase): The archive file.
+ """
+ if not isinstance(archive_file, IOBase):
+ archive_file = Path(archive_file)
+ functions.install_archive(self, archive_file)
+
def apply_update_archive(
self, archive_file: PathLike | IOBase, auto_repair: bool = True
) -> None: