feat(hsr): repair game

Apparently doesn't completely work on my setup (NTFS + Btrfs) but yours may. If it doesn't fix the game then copy the file it downloaded from temp to the game directory and it should work.
This commit is contained in:
tretrauit 2024-05-28 10:58:27 +07:00
parent bbdb8d3596
commit f5e7417cdf
6 changed files with 190 additions and 20 deletions

View File

@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
from os import PathLike
from pathlib import Path
from typing import Any
from vollerei.common.api import resource
class GameABC(ABC):
@ -70,6 +71,22 @@ class GameABC(ABC):
"""
pass
def repair_file(
self, file: PathLike, pre_download: bool = False, game_info=None
) -> None:
"""
Repairs a game file.
This will automatically handle backup and restore the file if the repair
fails.
Args:
file (PathLike): The file to repair.
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
"""
pass
def get_version(self) -> tuple[int, int, int]:
"""
Get the game version
@ -89,3 +106,16 @@ class GameABC(ABC):
Get the game channel
"""
pass
def get_remote_game(self, pre_download: bool = False) -> resource.Game:
"""
Gets the current game information from remote.
Args:
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
Returns:
A `Game` object that contains the game information.
"""
pass

View File

@ -515,6 +515,36 @@ class UpdateCommand(Command):
)
class RepairCommand(Command):
name = "hsr repair"
description = "Tries to repair the local game"
options = default_options
def handle(self):
callback(command=self)
self.line(
"This command will try to repair the game by downloading missing/broken files."
)
self.line(
"There will be no progress available, so please be patient and just wait."
)
if not self.confirm(
"Do you want to repair the game (this will take a long time!)?"
):
self.line("<error>Repairation aborted.</error>")
return
progress = utils.ProgressIndicator(self)
progress.start("Repairing game files (no progress available)... ")
try:
State.game.repair_game()
except Exception as e:
progress.finish(
f"<error>Repairation failed with following error: {e} ({e.__context__})</error>"
)
return
progress.finish("<comment>Repairation completed.</comment>")
class UpdateDownloadCommand(Command):
name = "hsr update download"
description = "Download the update for the local game if available"
@ -645,6 +675,7 @@ commands = [
PatchInstallCommand,
PatchTelemetryCommand,
PatchTypeCommand,
RepairCommand,
UpdatePatchCommand,
UpdateCommand,
UpdateDownloadCommand,

View File

@ -1,9 +1,11 @@
import concurrent.futures
from io import IOBase
import json
import hashlib
from pathlib import Path
import zipfile
from vollerei.abc.launcher.game import GameABC
from vollerei.exceptions.game import RepairError
from vollerei.utils import HDiffPatch, HPatchZPatchError
@ -133,3 +135,64 @@ def apply_update_archive(
# Close the archive
archive.close()
def repair_game(
game: GameABC,
pre_download: bool = False,
) -> None:
"""
Tries to repair the game by reading "pkg_version" file and downloading the
mismatched files from the server.
Because this function is shared for all games, you should use the game's
`repair_game()` method instead, which additionally applies required
methods for that game.
"""
# Most code here are copied from worthless-launcher.
# worthless-launcher uses asyncio for multithreading while this one uses
# ThreadPoolExecutor, probably better for this use case.
game_info = game.get_remote_game(pre_download=pre_download)
pkg_version_file = game.path.joinpath("pkg_version")
pkg_version = []
if not pkg_version_file.is_file():
try:
game.repair_file("pkg_version", game_info=game_info)
except Exception as e:
raise RepairError(
"pkg_version file not found, most likely you need to download the full game again."
) from e
else:
with pkg_version_file.open("r") as f:
for line in f.readlines():
line = line.strip()
if not line:
continue
pkg_version.append(json.loads(line))
repair_executor = concurrent.futures.ThreadPoolExecutor()
for file in pkg_version:
def repair(target_file, game_info):
try:
game.repair_file(target_file, game_info=game_info)
except Exception as e:
print(f"Failed to repair {target_file['remoteName']}: {e}")
def verify_and_repair(target_file, game_info):
file_path = game.path.joinpath(target_file["remoteName"])
if not file_path.is_file():
print(f"File {target_file['remoteName']} not found, repairing...")
repair(file_path, game_info)
return
with file_path.open("rb", buffering=0) as f:
file_hash = hashlib.file_digest(f, "md5").hexdigest()
if file_hash != target_file["md5"]:
print(
f"Hash mismatch for {target_file['remoteName']} ({file_hash}; expected {target_file['md5']})"
)
repair(file_path, game_info)
# Single-threaded for now
# verify_and_repair(file, game_info)
repair_executor.submit(verify_and_repair, file, game_info)
repair_executor.shutdown(wait=True)

View File

@ -25,7 +25,13 @@ class GameAlreadyInstalledError(GameError):
pass
class ScatteredFilesNotAvailableError(GameError):
class RepairError(GameError):
"""Error occurred while repairing the game."""
pass
class ScatteredFilesNotAvailableError(RepairError):
"""Scattered files are not available."""
pass

View File

@ -3,6 +3,7 @@ from hashlib import md5
from io import IOBase
from os import PathLike
from pathlib import Path
from shutil import move, copyfile
from vollerei.abc.launcher.game import GameABC
from vollerei.common import ConfigFile, functions
from vollerei.common.api import resource
@ -348,7 +349,12 @@ class Game(GameABC):
return diff
return None
def repair_file(self, file: PathLike, pre_download: bool = False) -> None:
def repair_file(
self,
file: PathLike,
pre_download: bool = False,
game_info: resource.Game = None,
) -> None:
"""
Repairs a game file.
@ -365,23 +371,52 @@ class Game(GameABC):
file = Path(file)
if not file.is_relative_to(self._path):
raise ValueError("File is not in the game folder.")
if not game_info:
game = self.get_remote_game(pre_download=pre_download)
else:
game = game_info
if game.latest.decompressed_path is None:
raise ScatteredFilesNotAvailableError("Scattered files are not available.")
url = game.latest.decompressed_path + "/" + file.relative_to(self._path)
# .replace("\\", "/") is needed because Windows uses backslashes :)
relative_file = file.relative_to(self._path)
url = (
game.latest.decompressed_path + "/" + str(relative_file).replace("\\", "/")
)
# Backup the file
if file.exists():
file.rename(file.with_suffix(file.suffix + ".bak"))
dest_file = file.with_suffix("")
else:
dest_file = file
try:
# Download the file
download(url, file.with_suffix(""))
except Exception:
temp_file = self.cache.joinpath(relative_file)
dest_file.parent.mkdir(parents=True, exist_ok=True)
print(f"Downloading repair file {url} to {temp_file}")
download(url, temp_file, overwrite=True, stream=True)
# Move the file
copyfile(temp_file, dest_file)
print("OK")
except Exception as e:
# Restore the backup
print("Failed", e)
if file.exists():
file.rename(file.with_suffix(""))
raise
raise e
else:
# Delete the backup
if file.exists():
file.unlink(missing_ok=True)
def repair_game(self) -> None:
"""
Tries to repair the game by reading "pkg_version" file and downloading the
mismatched files from the server.
"""
if not self.is_installed():
raise GameNotInstalledError("Game is not installed.")
functions.repair_game(self)
def apply_update_archive(
self, archive_file: PathLike | IOBase, auto_repair: bool = True
) -> None:

View File

@ -1,5 +1,6 @@
import requests
import platform
import shutil
from zipfile import ZipFile
from io import BytesIO
from pathlib import Path
@ -53,7 +54,11 @@ __all__ = [
def download(
url: str, out: Path, file_len: int = None, overwrite: bool = False
url: str,
out: Path,
file_len: int = None,
overwrite: bool = False,
stream: bool = True,
) -> None:
"""
Download to a path.
@ -65,22 +70,22 @@ def download(
if overwrite:
out.unlink(missing_ok=True)
headers = {}
mode = "a+b"
if out.exists():
cur_len = (out.stat()).st_size
headers |= {"Range": f"bytes={cur_len}-{file_len if file_len else ''}"}
else:
mode = "w+b"
out.parent.mkdir(parents=True, exist_ok=True)
out.touch()
# Streaming, so we can iterate over the response.
response = requests.get(url=url, headers=headers, stream=True)
response.raise_for_status()
if response.status == 416:
response = requests.get(url=url, headers=headers, stream=stream)
if response.status_code == 416:
print(f"File already downloaded: {out}")
return
# Sizes in bytes.
block_size = 32768
with out.open("ab") as file:
for data in response.iter_content(block_size):
file.write(data)
response.raise_for_status()
with open(out, mode) as file:
shutil.copyfileobj(response.raw, file)
return True