Compare commits

..

49 Commits

Author SHA1 Message Date
a084c61cae fix: disable repair command
The new HoYoPlay launcher doesn't have the ability to get scattered files from the remote anymore, "repairing" the game will just download the whole game again.
2025-05-21 04:22:38 +07:00
10d5f3f208 fix(common): merge zip file too 2025-03-03 17:04:05 +07:00
7707a64733 fix(common): bruh 2025-03-03 17:00:40 +07:00
76bccf6a6f fix(common): support zip file 2025-03-03 16:50:51 +07:00
874686a95c chore: change default path to user
So we can use it on Linux
2025-03-02 03:04:02 +07:00
1863fafd85 fix(common): manually close the merged archive 2025-02-26 18:48:46 +07:00
ac76df577b fix(common): install archive for split files
It worked before, but not now because I switched to py7zr backend for a while now.
2025-02-26 18:41:01 +07:00
ed641f890d fix(common): remove installed check 2025-02-02 22:53:27 +07:00
9e64bfc531 docs: fix again 2025-02-02 00:04:39 +07:00
25cfcdf0f0 fix: lol 2025-02-02 00:04:14 +07:00
b2eec5ee30 docs: fix 2025-02-02 00:01:32 +07:00
7dbe890bf3 fix: remove game channel from games
Lol
2025-02-01 23:58:09 +07:00
976308ac85 feat: unify multiple games into one 2025-02-01 23:51:53 +07:00
fba6cecc38 fix(common): add 'hdiffmap.json' to ignore list
So we don't extract the file lol.
2025-01-15 15:13:56 +07:00
f5007b6aa5 fix(common): compat with 'hdiffmap.json'
miHoYo introduced a new hdiff map, so yeah.
2025-01-14 21:51:31 +07:00
da9c930d2e fix(genshin): handle key error 2025-01-01 12:40:39 +07:00
fc3a43bec7 fix(genshin): voicepack english 2024-12-31 11:16:07 +07:00
844453eabc fix(genshin): voicepack detection 2024-12-31 01:52:01 +07:00
d6d1fdee6e fix: handle .zip update archive
Also fixes some minor bug
2024-12-18 19:10:34 +07:00
7440b6041f chore(ci): i forgor 2024-12-18 18:31:22 +07:00
75649df729 chore(ci): use poetry to install docs 2024-12-18 18:29:53 +07:00
707bcc14c3 chore(ci): fix 2024-12-18 18:27:31 +07:00
18aa7935cb chore(ci): fix 2024-12-18 18:21:12 +07:00
ecd204428d chore: docs 2024-12-18 18:18:33 +07:00
d920aea2b8 chore: add proxy game to readme 2024-12-18 17:40:43 +07:00
441a06fb5b fix(zzz); voicepack language detection 2024-12-18 17:39:48 +07:00
a33bdaa473 feat: implement zzz
It literally is just copy-pasting
2024-12-18 17:35:04 +07:00
4a7bc3d0b4 chore(cli): migrate to one file
Easier to maintain but slightly slower execution speed lol
2024-12-18 15:20:24 +07:00
1948d1f741 chore: add whatever spicy thingy here 2024-12-18 13:30:28 +07:00
6f384c1cc5 chore: add some information to readme 2024-12-13 19:42:36 +07:00
ebc0f2f3f5 feat(cli): support genshin
It literally is just copy-pasting lol, since almost all code are shared.
2024-12-13 19:35:51 +07:00
0c83958ee5 feat(cli/hsr): implement 'voicepack install' & other changes
Rename 'voicepack update-all' to 'voicepack update', allowing update only a voicepack instead of having to upate all of them
2024-12-13 19:30:54 +07:00
95ef9409f5 feat(cli/hsr): add 'hsr install download' command
Yeah
2024-12-13 13:22:38 +07:00
659366620b feat(cli/hsr): support game installation 2024-12-13 12:54:59 +07:00
5304cfbde1 chore: fix a bit
Wtf me
2024-12-13 11:59:51 +07:00
2331f9404a feat(game/genshin): initial copy of hsr code
Lol
2024-12-13 11:56:30 +07:00
a3df498444 chore: move repair-related functions to common
Preparation for Genshin Impact support xD
2024-12-13 11:26:48 +07:00
8ba00754ee chore: add more info to readme 2024-12-12 18:20:21 +07:00
4e0c4ed2c0 chore: refactor readme 2024-10-26 00:36:24 +07:00
7e5a60bb33 fix: applying update archive
It now works, previously I thought py7zr works the same as zipfile but it's different af so I had to change some code for that.
2024-10-23 20:46:44 +07:00
f02f1d5988 fix(common/update): zipfile -> py7zr
So py7zr does have some differences from zipfile
2024-10-22 13:10:14 +07:00
0bac04bdbd fix: use py7zr
Game archive now uses .7z, wow mihoyo
2024-10-22 00:34:57 +07:00
4d8f4008f2 fix(cli/hsr): download update now works
Also change e.__context__ to traceback.format_exc() to give better and actually useful error messages.
2024-10-21 13:34:38 +07:00
208c6efd1e fix(hsr/repair): fix the decompressed url
They changed it lol
2024-10-21 13:33:49 +07:00
156c42c1f3 fix: fix predownload object
Wow.
2024-10-21 13:33:17 +07:00
8ff2a388d7 fix: migrate to HoYoPlay
For now, only update game and voicepack are working

This commit also fixes a couple of bugs too. Tbf I enjoyed HoYoPlay until I don't have enough space to update my HSR so yeah 💀
2024-09-10 16:47:20 +07:00
08c51d2fd8 feat(repair): rework the repair feature
Mostly usable now
2024-06-18 02:49:52 +07:00
e8f63f175f Merge branch 'master' of himeko.tretrauit.me:tretrauit/vollerei 2024-06-06 20:21:06 +07:00
acd457babe fix(repair): join game path with pkg_version 2024-06-06 20:19:45 +07:00
52 changed files with 4213 additions and 1751 deletions

52
.github/workflows/docs.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Build & Deploy
on:
push:
pull_request:
workflow_dispatch:
jobs:
# Build job
build:
# Specify runner + build & upload the static files as an artifact
runs-on: ubuntu-latest
steps:
- name: Checkout code
id: checkout-code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip' # caching pip dependencies
- name: Install dependencies
id: install
run: |
pipx install poetry --python $(which python)
poetry install --only docs
- name: Build docs
id: build
run: |
poetry run mkdocs build
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v3 # or specific "vX.X.X" version tag for this action
with:
path: site/
# Deployment job
deploy:
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@ -4,6 +4,17 @@
An open-source launcher for anime games An open-source launcher for anime games
## Documentation
https://tretrauit.me/vollerei/
## Installation
Assumming you have `pipx` installed, execute the following command:
```bash
pipx install git+https://git.tretrauit.me/tretrauit/vollerei --preinstall tqdm
```
## Why? ## Why?
I've done [worthless-launcher](https://tretrauit.gitlab.io/worthless-launcher) for an open-world anime game, I've done [worthless-launcher](https://tretrauit.gitlab.io/worthless-launcher) for an open-world anime game,
@ -11,9 +22,33 @@ since I want to support other anime games and the launcher code is very messy, t
## Features ## Features
+ Nothing, I haven't written any code yet. ### Turn-based game / Open-world game / Proxy game
- [x] Cross-platform support
> Tested on Windows and Linux myself, although should work on most major OSes where `HDiffPatch` is supported.
- [x] Does *not* require administrator/root privileges
> Though if issues occur during installation/upgrading process, you can always try running the program with elevated privileges to fix them.
- [x] Download the game update (including pre-downloads if available)
- [x] Get the game version
- [x] Get installed voicepacks
- [x] Installation
- [x] Patch the game for unsupported platforms (with telemetry checking)
> Not applicable for the "Open-world game", since patching is unneeded to play the game.
- [x] Repair the game (Smarter than the official launcher!)
- [x] Update the game
- [x] Update voicepacks
- [ ] Uninstall the game (Just remove the game directory lol)
- [x] Voicepacks installation
#### Advanced features
- [x] Apply the update archives
- [x] Download the update archives
- [x] Easy to use API
### Notes ### Other games (Dreamseeker game)
I haven't developed for them yet, but since most of the code is shared I'll do that when I have the motivation to do so.
~~Help me get motivated by going to https://paypal.me/tretrauit and send me a coffee lol~~
## Notes
This launcher tries to mimic the official launcher behaviour as much as possible but if a ban appears, I will This launcher tries to mimic the official launcher behaviour as much as possible but if a ban appears, I will
not be responsible for it. (*Turn-based game* have a ban wave already, see AAGL discord for more info) not be responsible for it. (*Turn-based game* have a ban wave already, see AAGL discord for more info)

7
docs/abc/game.md Normal file
View File

@ -0,0 +1,7 @@
# Game
::: vollerei.abc.launcher.game.GameABC
handler: python
options:
show_root_heading: true
show_source: true

9
docs/common/functions.md Normal file
View File

@ -0,0 +1,9 @@
# Function for all games
Since you should use the specific implementation for the target game instead, these functions may not be correctly documented.
::: vollerei.common.functions
handler: python
options:
show_root_heading: true
show_source: true

View File

@ -0,0 +1,7 @@
# Game
::: vollerei.game.launcher.Game
handler: python
options:
show_root_heading: true
show_source: true

7
docs/genshin/game.md Normal file
View File

@ -0,0 +1,7 @@
# Game
::: vollerei.genshin.Game
handler: python
options:
show_root_heading: true
show_source: true

7
docs/hsr/game.md Normal file
View File

@ -0,0 +1,7 @@
# Game
::: vollerei.hsr.Game
handler: python
options:
show_root_heading: true
show_source: true

7
docs/hsr/patcher.md Normal file
View File

@ -0,0 +1,7 @@
# Patcher
::: vollerei.hsr.Patcher
handler: python
options:
show_root_heading: true
show_source: true

8
docs/hsr/patchtype.md Normal file
View File

@ -0,0 +1,8 @@
# PatchType
::: vollerei.hsr.PatchType
handler: python
options:
show_root_heading: true
show_source: true

17
docs/index.md Normal file
View File

@ -0,0 +1,17 @@
# Welcome to MkDocs
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
## Commands
* `mkdocs new [dir-name]` - Create a new project.
* `mkdocs serve` - Start the live-reloading docs server.
* `mkdocs build` - Build the documentation site.
* `mkdocs -h` - Print help message and exit.
## Project layout
mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.

7
docs/zzz/game.md Normal file
View File

@ -0,0 +1,7 @@
# Game
::: vollerei.zzz.Game
handler: python
options:
show_root_heading: true
show_source: true

38
mkdocs.yml Normal file
View File

@ -0,0 +1,38 @@
site_name: Vollerei
site_url: https://tretrauit.me/vollerei/
site_author: tretrauit
site_description: >-
Documentation for the Vollerei project, a Python library for managing and
interacting with HoYoVerse games.
repo_name: teppyboy/vollerei
repo_url: https://github.com/teppyboy/vollerei
# Copyright
copyright: Copyright © 2023 - 2024 tretrauit
theme:
name: material
palette:
- media: "(prefers-color-scheme)"
toggle:
icon: material/link
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: indigo
toggle:
icon: material/toggle-switch
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
accent: indigo
toggle:
icon: material/toggle-switch-off
name: Switch to system preference
plugins:
- search
- mkdocstrings

1321
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,8 @@ platformdirs = "^3.5.1"
requests = "^2.31.0" requests = "^2.31.0"
cleo = "^2.1.0" cleo = "^2.1.0"
packaging = "^23.2" packaging = "^23.2"
py7zr = "^0.22.0"
multivolumefile = "^0.2.3"
[tool.poetry.group.cli] [tool.poetry.group.cli]
optional = true optional = true
@ -26,6 +28,11 @@ optional = true
pytest = "^7.3.1" pytest = "^7.3.1"
pre-commit = "^3.3.3" pre-commit = "^3.3.3"
[tool.poetry.group.docs.dependencies]
mkdocstrings-python = "^1.12.2"
mkdocs-material = "^9.5.49"
[tool.poetry.scripts] [tool.poetry.scripts]
vollerei = 'vollerei.cli:run' vollerei = 'vollerei.cli:run'

View File

@ -39,9 +39,6 @@ class GameABC(ABC):
Args: Args:
game_path (PathLike, optional): Path to install the game to. game_path (PathLike, optional): Path to install the game to.
Returns:
None
""" """
pass pass
@ -87,6 +84,28 @@ class GameABC(ABC):
""" """
pass pass
def repair_files(
self,
files: list[PathLike],
pre_download: bool = False,
game_info: resource.Game = None,
) -> None:
"""
Repairs multiple game files.
This will automatically handle backup and restore the file if the repair
fails.
This method is not multi-threaded, so it may take a while to repair
multiple files.
Args:
files (PathLike): The files to repair.
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
"""
pass
def get_version(self) -> tuple[int, int, int]: def get_version(self) -> tuple[int, int, int]:
""" """
Get the game version Get the game version
@ -95,7 +114,21 @@ class GameABC(ABC):
""" """
pass pass
def get_update(self): 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.
"""
pass
def get_update(self) -> resource.Patch | None:
""" """
Get the game update Get the game update
""" """
@ -107,7 +140,9 @@ class GameABC(ABC):
""" """
pass pass
def get_remote_game(self, pre_download: bool = False) -> resource.Game: def get_remote_game(
self, pre_download: bool = False
) -> resource.Main | resource.PreDownload:
""" """
Gets the current game information from remote. Gets the current game information from remote.

View File

@ -1,9 +1,9 @@
from cleo.application import Application from cleo.application import Application
from vollerei.cli import hsr from vollerei.cli import commands
application = Application() application = Application()
for command in hsr.commands: for command in commands.exports:
application.add(command()) application.add(command)
def run(): def run():

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,35 @@
from vollerei.common.api.resource import Resource import requests
from vollerei.common.api import resource
from vollerei.common.enums import GameChannel
from vollerei.constants import LAUNCHER_API
__all__ = ["Resource"] __all__ = ["GamePackage"]
def get_game_packages(
channel: GameChannel = GameChannel.Overseas,
) -> list[resource.GameInfo]:
"""
Get game packages information from the launcher API.
Default channel is overseas.
Args:
channel: Game channel to get the resource information from.
Returns:
Resource: Game resource information.
"""
resource_path: dict = None
match channel:
case GameChannel.Overseas:
resource_path = LAUNCHER_API.OS
case GameChannel.China:
resource_path = LAUNCHER_API.CN
return resource.from_dict(
requests.get(
resource_path["url"] + LAUNCHER_API.RESOURCE_PATH,
params=resource_path["params"],
).json()["data"]
)

View File

@ -1,409 +1,156 @@
"""
Class wrapper for API endpoint /resource
"""
from vollerei.common.enums import VoicePackLanguage from vollerei.common.enums import VoicePackLanguage
from typing import Union
class Segment:
"""
A segment of the game archive.
Attributes:
path (str): Segment download path.
md5 (str): Segment md5 checksum.
package_size (int | None): Segment package size.
"""
path: str
md5: str
# str -> int and checked if int is 0 then None
package_size: int | None
def __init__(self, path: str, md5: str, package_size: int | None) -> None:
self.path = path
self.md5 = md5
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "Segment":
return Segment(
data["path"],
data["md5"],
(
int(data["package_size"])
if data["package_size"] and data["package_size"] != "0"
else None
),
)
class VoicePack:
"""
Voice pack information
`name` maybe converted from `path` if the server returns empty string.
Attributes:
language (VoicePackLanguage): Language of the voice pack.
name (str): Voice pack archive name.
path (str): Voice pack download path.
size (int): Voice pack size.
md5 (str): Voice pack md5 checksum.
package_size (int): Voice pack package size.
"""
language: VoicePackLanguage
name: str
path: str
# str -> int
size: int
md5: str
# str -> int
package_size: int
def __init__(
self,
language: VoicePackLanguage,
name: str,
path: str,
size: int,
md5: str,
package_size: int,
) -> None:
self.language = language
self.name = name
self.path = path
self.size = size
self.md5 = md5
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "VoicePack":
return VoicePack(
VoicePackLanguage.from_remote_str(data["language"]),
data["name"],
data["path"],
int(data["size"]),
data["md5"],
int(data["package_size"]),
)
class Diff:
"""
Game resource diff from a version to latest information
Attributes:
TODO
"""
name: str
version: str
path: str
# str -> int
size: int
md5: str
is_recommended_update: bool
voice_packs: list[VoicePack]
# str -> int
package_size: int
def __init__(
self,
name: str,
version: str,
path: str,
size: int,
md5: str,
is_recommended_update: bool,
voice_packs: list[VoicePack],
package_size: int,
) -> None:
self.name = name
self.version = version
self.path = path
self.size = size
self.md5 = md5
self.is_recommended_update = is_recommended_update
self.voice_packs = voice_packs
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "Diff":
return Diff(
data["name"],
data["version"],
data["path"],
int(data["size"]),
data["md5"],
data["is_recommended_update"],
[VoicePack.from_dict(i) for i in data["voice_packs"]],
int(data["package_size"]),
)
class Latest:
"""
Latest game resource information
`name` maybe converted from `path` if the server returns empty string,
and if `path` is empty too then it'll convert the name from the first
segment of `segments` list.
`path` maybe None if the server returns empty string, in that case
you'll have to download the game using `segments` list and merge them.
`voice_packs` will be empty for Star Rail, they force you to download
in-game instead.
`decompressed_path` is useful for repairing game files by only having
to re-download the corrupted files.
`segments` is a list of game archive segments, you'll have to download
them and merge them together to get the full game archive. Not available
on Star Rail.
Attributes:
name (str): Game archive name.
version (str): Game version in the archive.
path (str | None): Game archive download path.
size (int): Game archive size in bytes.
md5 (str): Game archive MD5 checksum.
entry (str): Game entry file (e.g. GenshinImpact.exe).
voice_packs (list[VoicePack]): Game voice packs.
decompressed_path (str | None): Game archive decompressed path.
segments (list[Segment]): Game archive segments.
package_size (int): Game archive package size in bytes.
"""
name: str
version: str
path: str | None
# str -> int
size: int
md5: str
entry: str
voice_packs: list[VoicePack]
# str but checked for empty string
decompressed_path: str | None
segments: list[Segment]
# str -> int
package_size: int
def __init__(
self,
name: str,
version: str,
path: str,
size: int,
md5: str,
entry: str,
voice_packs: list[VoicePack],
decompressed_path: str | None,
segments: list[Segment],
package_size: int,
) -> None:
self.name = name
self.version = version
self.path = path
self.size = size
self.md5 = md5
self.entry = entry
self.voice_packs = voice_packs
self.decompressed_path = decompressed_path
self.segments = segments
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "Latest":
if data["name"] == "":
if data["path"] == "":
data["name"] = data["segments"][0]["path"].split("/")[-1]
else:
data["name"] = data["path"].split("/")[-1]
return Latest(
data["name"],
data["version"],
data["path"] if data["path"] != "" else None,
int(data["size"]),
data["md5"],
data["entry"],
[VoicePack.from_dict(i) for i in data["voice_packs"]],
data["decompressed_path"] if data["decompressed_path"] != "" else None,
[Segment.from_dict(i) for i in data["segments"]],
int(data["package_size"]),
)
class Game: class Game:
latest: Latest def __init__(self, id: str, biz: str):
diffs: list[Diff] self.id = id
self.biz = biz
def __init__(self, latest: Latest, diffs: list[Diff]) -> None:
self.latest = latest
self.diffs = diffs
@staticmethod @staticmethod
def from_dict(data: dict) -> "Game": def from_dict(data: dict) -> "Game":
return Game( return Game(id=data["id"], biz=data["biz"])
Latest.from_dict(data["latest"]), [Diff.from_dict(i) for i in data["diffs"]]
)
class Plugin: class GamePackage:
name: str def __init__(self, url: str, md5: str, size: int, decompressed_size: int):
# str but checked for empty string self.url = url
version: str | None self.md5 = md5
path: str
# str -> int
size: int
md5: str
# str but checked for empty string
entry: str | None
# str -> int
package_size: int
def __init__(
self,
name: str,
version: str | None,
path: str,
size: int,
md5: str,
entry: str | None,
package_size: int,
) -> None:
self.name = name
self.version = version
self.path = path
self.size = size self.size = size
self.md5 = md5 self.decompressed_size = decompressed_size
self.entry = entry
self.package_size = package_size
@staticmethod @staticmethod
def from_dict(data: dict) -> "Plugin": def from_dict(data: list[dict]) -> list["GamePackage"]:
return Plugin( game_pkgs = []
data["name"], for pkg in data:
data["version"] if data["version"] != "" else None, game_pkgs.append(
data["path"], GamePackage(
int(data["size"]), url=pkg["url"],
data["md5"], md5=pkg["md5"],
data["entry"] if data["entry"] != "" else None, size=int(pkg["size"]),
int(data["package_size"]), decompressed_size=int(pkg["decompressed_size"]),
) )
)
return game_pkgs
class LauncherPlugin: class AudioPackage:
plugins: list[Plugin]
# str -> int
version: int
def __init__(self, plugins: list[Plugin], version: int) -> None:
self.plugins = plugins
self.version = version
@staticmethod
def from_dict(data: dict) -> "LauncherPlugin":
return LauncherPlugin(
[Plugin.from_dict(i) for i in data["plugins"]], int(data["version"])
)
class DeprecatedPackage:
name: str
md5: str
def __init__(self, name: str, md5: str) -> None:
self.name = name
self.md5 = md5
@staticmethod
def from_dict(data: dict) -> "DeprecatedPackage":
return DeprecatedPackage(data["name"], data["md5"])
class DeprecatedFile:
path: str
# str but checked for empty string
md5: str | None
def __init__(self, path: str, md5: str | None) -> None:
self.path = path
self.md5 = md5
@staticmethod
def from_dict(data: dict) -> "DeprecatedFile":
return DeprecatedFile(data["path"], data["md5"] if data["md5"] != "" else None)
class Resource:
"""
Data class for /resource endpoint
I'm still unclear about `force_update` and `sdk` attributes, so I'll
leave them as None for now.
Attributes:
game (Game): Game resource information.
plugin (LauncherPlugin): Launcher plugin information.
web_url (str): Game official launcher web URL.
force_update (None): Not used by official launcher I guess?
pre_download_game (Game | None): Pre-download game resource information.
deprecated_packages (list[DeprecatedPackage]): Deprecated game packages.
sdk (None): Maybe for Bilibili version of Genshin?
deprecated_files (list[DeprecatedFile]): Deprecated game files.
"""
# I'm generous enough to convert the string into int
# for you guys, wtf Mihoyo?
game: Game
# ?? Mihoyo for plugin["plugins"] which is a list of Plugin objects
plugin: LauncherPlugin
web_url: str
# ?? Mihoyo
force_update: None
# Will be a Game object if a pre-download is available.
pre_download_game: Game | None
deprecated_packages: list[DeprecatedPackage]
# Maybe a SDK for Bilibili version in Genshin?
sdk: None
deprecated_files: list[DeprecatedFile]
def __init__( def __init__(
self, self,
game: Game, language: VoicePackLanguage,
plugin: Plugin, url: str,
web_url: str, md5: str,
force_update: None, size: int,
pre_download_game: Game | None, decompressed_size: int,
deprecated_packages: list[DeprecatedPackage], ):
sdk: None, self.language = language
deprecated_files: list[DeprecatedFile], self.url = url
) -> None: self.md5 = md5
self.game = game self.size = size
self.plugin = plugin self.decompressed_size = decompressed_size
self.web_url = web_url
self.force_update = force_update
self.pre_download_game = pre_download_game
self.deprecated_packages = deprecated_packages
self.sdk = sdk
self.deprecated_files = deprecated_files
@staticmethod @staticmethod
def from_dict(json: dict) -> "Resource": def from_dict(data: list[dict]) -> "AudioPackage":
return Resource( audio_pkgs = []
Game.from_dict(json["game"]), for pkg in data:
LauncherPlugin.from_dict(json["plugin"]), audio_pkgs.append(
json["web_url"], AudioPackage(
json["force_update"], language=VoicePackLanguage.from_remote_str(pkg["language"]),
( url=pkg["url"],
Game.from_dict(json["pre_download_game"]) md5=pkg["md5"],
if json["pre_download_game"] size=int(pkg["size"]),
else None decompressed_size=int(pkg["decompressed_size"]),
), )
[DeprecatedPackage.from_dict(x) for x in json["deprecated_packages"]], )
json["sdk"], return audio_pkgs
[DeprecatedFile.from_dict(x) for x in json["deprecated_files"]],
class Major:
def __init__(
self,
version: str,
game_pkgs: list[GamePackage],
audio_pkgs: list[AudioPackage],
res_list_url: str,
):
self.version = version
self.game_pkgs = game_pkgs
self.audio_pkgs = audio_pkgs
self.res_list_url = res_list_url
@staticmethod
def from_dict(data: dict) -> "Major":
return Major(
version=data["version"],
game_pkgs=GamePackage.from_dict(data["game_pkgs"]),
audio_pkgs=AudioPackage.from_dict(data["audio_pkgs"]),
res_list_url=data["res_list_url"],
) )
# Currently patch has the same fields as major
Patch = Major
class Main:
def __init__(self, major: Major, patches: list[Patch]):
self.major = major
self.patches = patches
@staticmethod
def from_dict(data: dict) -> "Main":
return Main(
major=Major.from_dict(data["major"]),
patches=[Patch.from_dict(x) for x in data["patches"]],
)
class PreDownload:
def __init__(self, major: Major | str | None, patches: list[Patch]):
self.major = major
self.patches = patches
# Union to fix the typing issue.
@staticmethod
def from_dict(data: dict | None) -> Union["PreDownload", None]:
# pre_download can be null in the server for certain games
# e.g. HI3:
# "pre_download": null
# while in GI it is the following:
# "pre_download": {
# "major": null,
# "patches": []
# }
if data is None:
return None
return PreDownload(
major=(
data["major"]
if isinstance(data["major"], str | None)
else Major.from_dict(data["major"])
),
patches=[Patch.from_dict(x) for x in data["patches"]],
)
# Why miHoYo uses the same name "game_packages" for this big field and smol field
class GameInfo:
def __init__(self, game: Game, main: Main, pre_download: PreDownload):
self.game = game
self.main = main
self.pre_download = pre_download
@staticmethod
def from_dict(data: dict) -> "GameInfo":
return GameInfo(
game=Game.from_dict(data["game"]),
main=Main.from_dict(data["main"]),
pre_download=PreDownload.from_dict(data["pre_download"]),
)
def from_dict(data: dict) -> list[GameInfo]:
game_pkgs = []
for pkg in data["game_packages"]:
game_pkgs.append(GameInfo.from_dict(pkg))
return game_pkgs

View File

@ -1,6 +1,18 @@
from enum import Enum from enum import Enum
class GameType(Enum):
Genshin = 0
HSR = 1
ZZZ = 3
HI3 = 4
class GameChannel(Enum):
Overseas = 0
China = 1
class VoicePackLanguage(Enum): class VoicePackLanguage(Enum):
Japanese = "ja-jp" Japanese = "ja-jp"
Chinese = "zh-cn" Chinese = "zh-cn"
@ -25,3 +37,23 @@ class VoicePackLanguage(Enum):
return VoicePackLanguage.English return VoicePackLanguage.English
else: else:
raise ValueError(f"Invalid language string: {s}") raise ValueError(f"Invalid language string: {s}")
@staticmethod
def from_zzz_name(s: str) -> "VoicePackLanguage":
"""
Converts a language string from ZZZ file name to a VoicePackLanguage enum.
Only English is tested for now.
"""
if s == "Jp":
return VoicePackLanguage.Japanese
elif s == "Cn":
return VoicePackLanguage.Chinese
elif s == "Tw":
return VoicePackLanguage.Taiwanese
elif s == "Kr":
return VoicePackLanguage.Korean
elif s == "En":
return VoicePackLanguage.English
else:
raise ValueError(f"Invalid language string: {s}")

View File

@ -1,17 +1,50 @@
import concurrent.futures import concurrent.futures
from io import IOBase
import json import json
import hashlib import hashlib
from pathlib import Path import multivolumefile
import py7zr
import zipfile import zipfile
from io import IOBase
from os import PathLike
from pathlib import Path
from shutil import move
from vollerei.abc.launcher.game import GameABC from vollerei.abc.launcher.game import GameABC
from vollerei.exceptions.game import RepairError from vollerei.common.api import resource
from vollerei.utils import HDiffPatch, HPatchZPatchError from vollerei.exceptions.game import (
RepairError,
GameNotInstalledError,
ScatteredFilesNotAvailableError,
)
from vollerei.utils import HDiffPatch, HPatchZPatchError, download
_hdiff = HDiffPatch() _hdiff = HDiffPatch()
def _extract_files(
archive: py7zr.SevenZipFile | zipfile.ZipFile, files, path: PathLike
):
if isinstance(archive, py7zr.SevenZipFile):
# .7z archive
archive.extract(path, files)
else:
# .zip archive
archive.extractall(path, files)
def _open_archive(file: Path | IOBase) -> py7zr.SevenZipFile | zipfile.ZipFile:
archive: py7zr.SevenZipFile | zipfile.ZipFile = None
try:
archive = py7zr.SevenZipFile(file, "r")
except py7zr.exceptions.Bad7zFile:
# Try to open it as a zip file
try:
archive = zipfile.ZipFile(file, "r")
except zipfile.BadZipFile:
raise ValueError("Archive is not a valid 7z or zip file.")
return archive
def apply_update_archive( def apply_update_archive(
game: GameABC, archive_file: Path | IOBase, auto_repair: bool = True game: GameABC, archive_file: Path | IOBase, auto_repair: bool = True
) -> None: ) -> None:
@ -31,25 +64,45 @@ def apply_update_archive(
# Install HDiffPatch # Install HDiffPatch
_hdiff.hpatchz() _hdiff.hpatchz()
# Open archive # Open archive
archive = zipfile.ZipFile(archive_file, "r") def reset_if_py7zr(archive):
if isinstance(archive, py7zr.SevenZipFile):
archive.reset()
archive = _open_archive(archive_file)
# Get files list (we don't want to extract all of them) # Get files list (we don't want to extract all of them)
files = archive.namelist() files = archive.namelist()
# Don't extract these files (they're useless and if the game isn't patched then # Don't extract these files (they're useless and if the game isn't patched then
# it'll raise 31-4xxx error in Genshin) # it'll raise 31-4xxx error in Genshin)
for file in ["deletefiles.txt", "hdifffiles.txt"]: for file in ["deletefiles.txt", "hdifffiles.txt", "hdiffmap.json"]:
try: try:
files.remove(file) files.remove(file)
except ValueError: except ValueError:
pass pass
# Think for me a better name for this variable
txtfiles = None
if isinstance(archive, py7zr.SevenZipFile):
txtfiles = archive.read(["deletefiles.txt", "hdifffiles.txt", "hdiffmap.json"])
# Reset archive to extract files
archive.reset()
try: try:
# miHoYo loves CRLF # miHoYo loves CRLF
deletefiles = archive.read("deletefiles.txt").decode().split("\r\n") if txtfiles is not None:
except IOError: deletebytes = txtfiles["deletefiles.txt"].read()
else:
deletebytes = archive.read("deletefiles.txt")
if deletebytes is not str:
# Typing
deletebytes: bytes
deletebytes = deletebytes.decode()
deletefiles = deletebytes.split("\r\n")
except (IOError, KeyError):
pass pass
else: else:
for file_str in deletefiles: for file_str in deletefiles:
file = game.path.joinpath(file) file = game.path.joinpath(file_str)
if file == game.path: if file == game.path:
# Don't delete the game folder # Don't delete the game folder
continue continue
@ -61,63 +114,89 @@ def apply_update_archive(
# hdiffpatch implementation # hdiffpatch implementation
# Read hdifffiles.txt to get the files to patch # Read hdifffiles.txt to get the files to patch
hdifffiles = [] # Hdifffile format is [(source file, target file)]
for x in archive.read("hdifffiles.txt").decode().split("\r\n"): # While the patch file is named as target file + ".hdiff"
try: hdifffiles: list[tuple[str, str]] = []
hdifffiles.append(json.loads(x.strip())["remoteName"]) new_hdiff_map = False
except json.JSONDecodeError: if txtfiles is not None:
pass old_hdiff_map = txtfiles.get("hdifffiles.txt")
if old_hdiff_map is not None:
hdiffbytes = old_hdiff_map.read()
else:
new_hdiff_map = True
hdiffbytes = txtfiles["hdiffmap.json"].read()
else:
# Archive file must be a zip file
if zipfile.Path(archive).joinpath("hdifffiles.txt").is_file():
hdiffbytes = archive.read("hdifffiles.txt")
else:
new_hdiff_map = True
hdiffbytes = archive.read("hdiffmap.json")
if hdiffbytes is not str:
# Typing
hdiffbytes: bytes
hdiffbytes = hdiffbytes.decode()
if new_hdiff_map:
mapping = json.loads(hdiffbytes)
for diff in mapping["diff_map"]:
hdifffiles.append((diff["source_file_name"], diff["target_file_name"]))
else:
for x in hdiffbytes.split("\r\n"):
try:
name = json.loads(x.strip())["remoteName"]
hdifffiles.append((name, name))
except json.JSONDecodeError:
pass
# Patch function # Patch function
def extract_and_patch(file, patch_file): def patch(source_file: Path, target_file: Path, patch_file: str):
patchpath = game.cache.joinpath(patch_file) patch_path = game.cache.joinpath(patch_file)
# Delete old patch file if exists # Spaghetti code :(, fuck my eyes.
patchpath.unlink(missing_ok=True) bak_src_file = source_file.rename(
# Extract patch file source_file.with_suffix(source_file.suffix + ".bak")
archive.extract(patch_file, game.temppath) )
file = file.rename(file.with_suffix(file.suffix + ".bak"))
try: try:
_hdiff.patch_file(file, file.with_suffix(""), patchpath) _hdiff.patch_file(bak_src_file, target_file, patch_path)
except HPatchZPatchError: except HPatchZPatchError:
if auto_repair: if auto_repair:
try: try:
game.repair_file(game.path.joinpath(file.with_suffix(""))) # The game repairs file by downloading the latest file, in this case we want the target file
# instead of source file. Honestly I haven't tested this but I hope it works.
game.repair_file(target_file)
except Exception: except Exception:
# Let the game download the file. # Let the game download the file.
file.rename(file.with_suffix("")) bak_src_file.rename(file.with_suffix(""))
else: else:
file.unlink() bak_src_file.unlink()
else: else:
# Let the game download the file. # Let the game download the file.
file.rename(file.with_suffix("")) bak_src_file.rename(file.with_suffix(""))
return return
else:
# Remove old file, since we don't need it anymore.
bak_src_file.unlink()
finally: finally:
patchpath.unlink() patch_path.unlink()
# Remove old file, since we don't need it anymore.
file.unlink()
def extract_or_repair(file):
# Extract file
try:
archive.extract(file, game.path)
except Exception as e:
# Repair file
if not auto_repair:
raise e
game.repair_file(game.path.joinpath(file))
# Multi-threaded patching # Multi-threaded patching
patch_jobs = [] patch_jobs = []
for file_str in hdifffiles: patch_files = []
file = game.path.joinpath(file_str) for source_file, target_file in hdifffiles:
if not file.exists(): source_path = game.path.joinpath(source_file)
if not source_path.exists():
# Not patching since we don't have the file # Not patching since we don't have the file
continue continue
patch_file: str = file_str + ".hdiff" target_path = game.path.joinpath(target_file)
patch_file: str = target_file + ".hdiff"
# Remove hdiff files from files list to extract # Remove hdiff files from files list to extract
files.remove(patch_file) files.remove(patch_file)
patch_jobs.append([extract_and_patch, [file, patch_file]]) # Add file to extract list
patch_files.append(patch_file)
patch_jobs.append([patch, [source_path, target_path, patch_file]])
# Extract patch files to temporary dir
_extract_files(archive, patch_files, game.cache)
reset_if_py7zr(archive) # For the next extraction
# Create new ThreadPoolExecutor for patching # Create new ThreadPoolExecutor for patching
patch_executor = concurrent.futures.ThreadPoolExecutor() patch_executor = concurrent.futures.ThreadPoolExecutor()
for job in patch_jobs: for job in patch_jobs:
@ -125,18 +204,113 @@ def apply_update_archive(
patch_executor.shutdown(wait=True) patch_executor.shutdown(wait=True)
# Extract files from archive after we have filtered out the patch files # Extract files from archive after we have filtered out the patch files
# Using ThreadPoolExecutor instead of archive.extractall() because _extract_files(archive, files, game.path)
# archive.extractall() can crash with large archives, and it doesn't
# handle broken files.
extract_executor = concurrent.futures.ThreadPoolExecutor()
for file in files:
extract_executor.submit(extract_or_repair, file)
extract_executor.shutdown(wait=True)
# Close the archive # Close the archive
archive.close() 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.
Args:
game (GameABC): The game to install the archive for.
archive_file (Path | IOBase): The archive file to install, if it's a
split archive then this is the first part.
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.
"""
archive: py7zr.SevenZipFile | zipfile.ZipFile = None
archive_path = Path(archive_file)
target_archive = None
if archive_path.suffix == ".001":
archive_path_merged = archive_path.with_suffix("")
target_archive = multivolumefile.open(archive_path_merged, mode='rb')
if archive_path_merged.suffix == ".zip":
# .zip split archive
archive = zipfile.ZipFile(target_archive, "r")
else:
archive = py7zr.SevenZipFile(target_archive, "r")
else:
archive = _open_archive(archive_file)
archive.extractall(game.path)
archive.close()
if target_archive:
target_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)
url = game_info.major.res_list_url + "/" + str(relative_file).replace("\\", "/")
# Backup the file
if file.exists():
backup_file = file.with_suffix(file.suffix + ".bak")
if backup_file.exists():
backup_file.unlink()
file.rename(backup_file)
dest_file = file.with_suffix("")
else:
dest_file = file
try:
# Download the file
temp_file = game.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
move(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 e
# Delete the backup
if file.exists():
file.unlink(missing_ok=True)
def repair_files(
game: GameABC,
files: list[PathLike],
pre_download: bool = False,
game_info: resource.Game = None,
) -> None:
"""
Repairs multiple game files.
This will automatically handle backup and restore the file if the repair
fails.
Args:
game (GameABC): The game to repair the files for.
files (PathLike): The files to repair.
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
"""
if not game.is_installed():
raise GameNotInstalledError("Game is not installed.")
files_path = [Path(file) for file in files]
for file in files_path:
if not file.is_relative_to(game.path):
raise ValueError("File is not in the game folder.")
if not game_info:
game_info = game.get_remote_game(pre_download=pre_download)
if game_info.latest.decompressed_path is None:
raise ScatteredFilesNotAvailableError("Scattered files are not available.")
executor = concurrent.futures.ThreadPoolExecutor()
for file in files_path:
executor.submit(_repair_file, file, game=game_info)
# self._repair_file(file, game=game)
executor.shutdown(wait=True)
def repair_game( def repair_game(
game: GameABC, game: GameABC,
pre_download: bool = False, pre_download: bool = False,
@ -152,9 +326,11 @@ def repair_game(
# Most code here are copied from worthless-launcher. # Most code here are copied from worthless-launcher.
# worthless-launcher uses asyncio for multithreading while this one uses # worthless-launcher uses asyncio for multithreading while this one uses
# ThreadPoolExecutor, probably better for this use case. # ThreadPoolExecutor, probably better for this use case.
if not game.is_installed():
raise GameNotInstalledError("Game is not installed.")
game_info = game.get_remote_game(pre_download=pre_download) game_info = game.get_remote_game(pre_download=pre_download)
pkg_version_file = game.path.joinpath("pkg_version") pkg_version_file = game.path.joinpath("pkg_version")
pkg_version = [] pkg_version: dict[str, dict[str, str]] = {}
if not pkg_version_file.is_file(): if not pkg_version_file.is_file():
try: try:
game.repair_file(game.path.joinpath("pkg_version"), game_info=game_info) game.repair_file(game.path.joinpath("pkg_version"), game_info=game_info)
@ -162,37 +338,66 @@ def repair_game(
raise RepairError( raise RepairError(
"pkg_version file not found, most likely you need to download the full game again." "pkg_version file not found, most likely you need to download the full game again."
) from e ) from e
else: with pkg_version_file.open("r") as f:
with pkg_version_file.open("r") as f: for line in f.readlines():
for line in f.readlines(): line = line.strip()
line = line.strip() if not line:
if not line: continue
continue line_json = json.loads(line)
pkg_version.append(json.loads(line)) pkg_version[line_json["remoteName"]] = {
"md5": line_json["md5"],
"fileSize": line_json["fileSize"],
}
read_needed_files: list[Path] = []
target_files: list[Path] = []
repair_executor = concurrent.futures.ThreadPoolExecutor() repair_executor = concurrent.futures.ThreadPoolExecutor()
for file in pkg_version: for file in game.path.rglob("*"):
# Ignore webCaches folder (because it's user data)
if file.is_dir():
continue
if "webCaches" in str(file):
continue
def repair(target_file, game_info): def verify(file_path: Path):
nonlocal target_files
nonlocal pkg_version
relative_path = file_path.relative_to(game.path)
relative_path_str = str(relative_path).replace("\\", "/")
# print(relative_path_str)
# Wtf mihoyo, you build this game for Windows and then use Unix path separator :moyai:
try: try:
game.repair_file(target_file, game_info=game_info) target_file = pkg_version.pop(relative_path_str)
except Exception as e: if target_file:
print(f"Failed to repair {target_file['remoteName']}: {e}") with file_path.open("rb", buffering=0) as f:
file_hash = hashlib.file_digest(f, "md5").hexdigest()
if file_hash == target_file["md5"]:
return
print(
f"Hash mismatch for {target_file['remoteName']} ({file_hash}; expected {target_file['md5']})"
)
target_files.append(file_path)
except KeyError:
# File not found in pkg_version
read_needed_files.append(file_path)
def verify_and_repair(target_file, game_info): repair_executor.submit(verify, file)
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) repair_executor.shutdown(wait=True)
for file in read_needed_files:
try:
with file.open("rb", buffering=0) as f:
# We only need to read 4 bytes to see if the file is readable or not
f.read(4)
except Exception:
print(f"File '{file}' is corrupted.")
target_files.append(file)
# value not used for now
for key, _ in pkg_version.items():
target_file = game.path.joinpath(key)
if target_file.is_file():
continue
print(f"{key} not found.")
target_files.append(target_file)
if not target_files:
return
print("Begin repairing files...")
game.repair_files(target_files, game_info=game_info)

View File

@ -1,3 +1,21 @@
class LAUNCHER_API:
"""Launcher API constants."""
RESOURCE_PATH: str = "hyp/hyp-connect/api/getGamePackages"
OS: dict = {
"url": "https://sg-hyp-api.hoyoverse.com/",
"params": {
"launcher_id": "VYTpXlbWo8",
},
}
CN: dict = {
"url": "https://hyp-api.mihoyo.com/",
"params": {
"launcher_id": "jGHBHlcOq1",
},
}
TELEMETRY_HOSTS = [ TELEMETRY_HOSTS = [
# Global # Global
"log-upload-os.hoyoverse.com", "log-upload-os.hoyoverse.com",

View File

@ -0,0 +1,86 @@
from vollerei.common.enums import GameChannel
from vollerei.abc.launcher.game import GameABC
from vollerei.exceptions.game import GameNotInstalledError
def get_channel(game: GameABC) -> GameChannel:
"""
Gets the current game channel.
Returns:
GameChannel: The current game channel.
"""
if game.channel_override:
return game.channel_override
if not game.is_installed():
raise GameNotInstalledError("Game path is not set.")
if game.path.joinpath("YuanShen.exe").is_file():
return GameChannel.China
return GameChannel.Overseas
def get_version(game: GameABC) -> 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/genshin/game.rs#L52
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 = game.data_folder().joinpath("globalgamemanagers")
if not data_file.exists():
return game.get_version_config()
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
version_bytes: list[list[bytes]] = [[], [], []]
version_ptr = 0
correct = True
try:
with data_file.open("rb") as f:
f.seek(4000)
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 95:
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 b"0123456789":
version_bytes[version_ptr].append(byte)
else:
correct = False
except Exception:
pass
# Fallback to config.ini
return game.get_version_config()

View File

@ -0,0 +1,15 @@
MD5SUMS = {
"1.0.5": {
"cn": {
"StarRailBase.dll": "66c42871ce82456967d004ccb2d7cf77",
"UnityPlayer.dll": "0c866c44bb3752031a8c12ffe935b26f",
},
"os": {
"StarRailBase.dll": "8aa3790aafa3dd176678392f3f93f435",
"UnityPlayer.dll": "f17b9b7f9b8c9cbd211bdff7771a80c2",
},
}
}
# Patches
ASTRA_REPO = "https://notabug.org/mkrsym1/astra"
JADEITE_REPO = "https://codeberg.org/mkrsym1/jadeite/"

View File

@ -0,0 +1,98 @@
from hashlib import md5
from vollerei.common.enums import GameChannel
from vollerei.abc.launcher.game import GameABC
from vollerei.game.hsr.constants import MD5SUMS
def get_channel(game: GameABC) -> GameChannel | None:
"""
Gets the current game channel.
Only works for Star Rail version 1.0.5, other versions will return the
overridden channel or GameChannel.Overseas 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 = game.version_override or game.get_version()
if version == (1, 0, 5):
for channel, v in MD5SUMS["1.0.5"].values():
for file, md5sum in v.values():
if md5(game.path.joinpath(file).read_bytes()).hexdigest() != md5sum:
continue
match channel:
case "cn":
return GameChannel.China
case "os":
return GameChannel.Overseas
return None
def get_version(game: GameABC) -> 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 = game.data_folder().joinpath("data.unity3d")
if not data_file.exists():
return game.get_version_config()
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
version_bytes: list[list[bytes]] = [[], [], []]
version_ptr = 0
correct = True
try:
with data_file.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 b"0123456789":
version_bytes[version_ptr].append(byte)
else:
correct = False
except Exception:
pass
# Fallback to config.ini
return game.get_version_config()

View File

@ -0,0 +1,5 @@
# Re-exports
from vollerei.game.launcher.manager import Game
__all__ = ["Game"]

View File

@ -0,0 +1,32 @@
from vollerei.common.api import get_game_packages, resource
from vollerei.common.enums import GameChannel, GameType
def get_game_package(
game_type: GameType, channel: GameChannel = GameChannel.Overseas
) -> resource.GameInfo:
"""
Get game package information from the launcher API.
Doesn't work with HI3 but well, we haven't implemented anything for that game yet.
Default channel is overseas.
Args:
channel: Game channel to get the resource information from.
Returns:
GameInfo: Game resource information.
"""
find_str: str
match game_type:
case GameType.HSR:
find_str = "hkrpg"
case GameType.Genshin:
find_str = "hk4e"
case GameType.ZZZ:
find_str = "nap"
game_packages = get_game_packages(channel=channel)
for package in game_packages:
if find_str in package.game.biz:
return package

View File

View File

@ -0,0 +1,510 @@
from configparser import ConfigParser
from io import IOBase
from os import PathLike
from pathlib import Path, PurePath
from vollerei.abc.launcher.game import GameABC
from vollerei.common import ConfigFile, functions
from vollerei.common.api import resource
from vollerei.common.enums import GameType, VoicePackLanguage, GameChannel
from vollerei.exceptions.game import (
GameAlreadyUpdatedError,
GameNotInstalledError,
PreDownloadNotAvailable,
)
from vollerei.game.launcher import api
from vollerei.game.hsr import functions as hsr_functions
from vollerei.game.genshin import functions as genshin_functions
from vollerei.game.zzz import functions as zzz_functions
from vollerei import paths
from vollerei.utils import download
class Game(GameABC):
"""
Manages the game installation
For Star Rail and Zenless Zone Zero:
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, game_type: GameType, 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._game_type = game_type
self.cache: Path = cache_path.joinpath(f"game/{self._game_type.name.lower()}/")
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.
Returns:
Path: The path to the game data folder.
"""
try:
match self._game_type:
case GameType.Genshin:
match self.get_channel():
case GameChannel.China:
return self._path.joinpath("YuanShen_Data")
case GameChannel.Overseas:
return self._path.joinpath("GenshinImpact_Data")
case GameType.HSR:
return self._path.joinpath("StarRail_Data")
case GameType.ZZZ:
return self._path.joinpath("ZenlessZoneZero_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
match self._game_type:
case GameType.Genshin:
match self.get_channel():
case GameChannel.China:
if not self._path.joinpath("YuanShen.exe").exists():
return False
case GameChannel.Overseas:
if not self._path.joinpath("GenshinImpact.exe").exists():
return False
case GameType.HSR:
if not self._path.joinpath("StarRail.exe").exists():
return False
case GameType.ZZZ:
if not self._path.joinpath("ZenlessZoneZero.exe").exists():
return False
if not self.data_folder().is_dir():
return False
if self.get_version() == (0, 0, 0):
return False
return True
def get_channel(self) -> GameChannel:
"""
Gets the current game channel.
Only works for Genshin and Star Rail version 1.0.5, other versions will return
the overridden channel or GameChannel.Overseas 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.
"""
match self._game_type:
case GameType.HSR:
return (
hsr_functions.get_channel(self)
or self._channel_override
or GameChannel.Overseas
)
case GameType.Genshin:
return (
genshin_functions.get_channel(self)
or self._channel_override
or GameChannel.Overseas
)
case _:
return self._channel_override or GameChannel.Overseas
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)
# Fk u miHoYo
if "general" in cfg.sections():
version_str = cfg.get("general", "game_version", fallback="0.0.0")
elif "General" in cfg.sections():
version_str = cfg.get("General", "game_version", fallback="0.0.0")
else:
return (0, 0, 0)
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.
Only works for the global version of the game, not the Chinese one (since I don't have
them installed to test).
This method is meant to keep compatibility with the official launcher only.
"""
cfg_file = self._path.joinpath("config.ini")
if cfg_file.exists():
cfg = ConfigFile(cfg_file)
cfg.set("general", "game_version", self.get_version_str())
cfg.save()
else:
cfg_dict = {
"general": {
"channel": 1,
"cps": "hyp_hoyoverse",
"game_version": self.get_version_str(),
"sub_channel": 0,
# This probably should be fetched from the server but well
"plugin_n06mjyc2r3_version": "1.1.0",
"uapc": None, # Honestly what's this?
}
}
match self._game_type:
case GameType.Genshin:
cfg_dict["general"]["uapc"] = {
"hk4e_global": {"uapc": "f55586a8ce9f_"},
"hyp": {"uapc": "f55586a8ce9f_"},
}
case GameType.HSR:
cfg_dict["general"]["uapc"] = {
"hkrpg_global": {"uapc": "f5c7c6262812_"},
"hyp": {"uapc": "f55586a8ce9f_"},
}
case GameType.ZZZ:
cfg_dict["general"]["uapc"] = {
"nap_global": {"uapc": "f55586a8ce9f_"},
"hyp": {"uapc": "f55586a8ce9f_"},
}
cfg = ConfigParser()
cfg.read_dict(cfg_dict)
cfg.write(cfg_file.open("w"))
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, see the source
in `hsr/functions.py`, `genshin/functions.py` and `zzz/functions.py` for more info
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.
"""
match self._game_type:
case GameType.HSR:
return hsr_functions.get_version(self)
case GameType.Genshin:
return genshin_functions.get_version(self)
case GameType.ZZZ:
return zzz_functions.get_version(self)
case _:
return self.get_version_config()
def get_version_str(self) -> str:
"""
Gets the current installed game version as a string.
Because this method uses `get_version()`, you should read the docs of
that method too.
Returns:
str: The version as a string.
"""
return ".".join(str(i) for i in self.get_version())
def get_installed_voicepacks(self) -> list[VoicePackLanguage]:
"""
Gets the installed voicepacks.
Returns:
list[VoicePackLanguage]: A list of installed voicepacks.
"""
if not self.is_installed():
raise GameNotInstalledError("Game is not installed.")
voicepacks = []
blacklisted_words = ["SFX"]
audio_package: Path
match self._game_type:
case GameType.Genshin:
audio_package = self.data_folder().joinpath(
"StreamingAssets/AudioAssets/"
)
if audio_package.joinpath("AudioPackage").is_dir():
audio_package = audio_package.joinpath("AudioPackage")
case GameType.HSR:
audio_package = self.data_folder().joinpath(
"Persistent/Audio/AudioPackage/Windows/"
)
case GameType.ZZZ:
audio_package = self.data_folder().joinpath(
"StreamingAssets/Audio/Windows/Full/"
)
for child in audio_package.iterdir():
if child.resolve().is_dir() and child.name not in blacklisted_words:
name = child.name
if name.startswith("English"):
name = "English"
voicepack: VoicePackLanguage
try:
if self._game_type == GameType.ZZZ:
voicepack = VoicePackLanguage.from_zzz_name(child.name)
else:
voicepack = VoicePackLanguage[name]
voicepacks.append(voicepack)
except (ValueError, KeyError):
pass
return voicepacks
def get_remote_game(
self, pre_download: bool = False
) -> resource.Main | resource.PreDownload:
"""
Gets the current game information from remote.
Args:
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
Returns:
A `Main` or `PreDownload` object that contains the game information.
"""
channel = self._channel_override or self.get_channel()
if pre_download:
game = api.get_game_package(
game_type=self._game_type, channel=channel
).pre_download
if not game:
raise PreDownloadNotAvailable("Pre-download version is not available.")
return game
return api.get_game_package(game_type=self._game_type, channel=channel).main
def get_update(self, pre_download: bool = False) -> resource.Patch | None:
"""
Gets the current game update.
Args:
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
Returns:
A `Patch` 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(str(x) for x in self._version_override)
if self._version_override
else self.get_version_str()
)
for patch in self.get_remote_game(pre_download=pre_download).patches:
if patch.version == version:
return patch
return None
def repair_file(
self,
file: PathLike,
pre_download: bool = False,
game_info: resource.Game = 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.
"""
return self.repair_files([file], pre_download=pre_download, game_info=game_info)
def repair_files(
self,
files: list[PathLike],
pre_download: bool = False,
game_info: resource.Game = None,
) -> None:
"""
Repairs multiple game files.
This will automatically handle backup and restore the file if the repair
fails.
Args:
files (PathLike): The files to repair.
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
game_info (resource.Game): The game information to use for repair.
"""
functions.repair_files(
self, files, pre_download=pre_download, game_info=game_info
)
def repair_game(self) -> None:
"""
Tries to repair the game by reading "pkg_version" file and downloading the
mismatched files from the server.
"""
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:
"""
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.
Defaults to True.
"""
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)
def install_update(
self, update_info: resource.Patch = None, auto_repair: bool = True
):
"""
Installs an update from a `Patch` object.
You may want to download the update manually and pass it to
`apply_update_archive()` instead for better control, and after that
execute `set_version_config()` to set the game version.
Args:
update_info (Diff, optional): The update information. Defaults to None.
auto_repair (bool, optional): Whether to repair the file if it's broken.
Defaults to True.
"""
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.")
update_url = update_info.game_pkgs[0].url
# Base game update
archive_file = self.cache.joinpath(PurePath(update_url).name)
download(update_url, archive_file)
self.apply_update_archive(archive_file=archive_file, auto_repair=auto_repair)
# Get installed voicepacks
installed_voicepacks = self.get_installed_voicepacks()
# Voicepack update
for remote_voicepack in update_info.audio_pkgs:
if remote_voicepack.language not in installed_voicepacks:
continue
# Voicepack is installed, update it
archive_file = self.cache.joinpath(PurePath(remote_voicepack.url).name)
download(remote_voicepack.url, archive_file)
self.apply_update_archive(
archive_file=archive_file, auto_repair=auto_repair
)
self.set_version_config()

223
vollerei/game/patcher.py Normal file
View File

@ -0,0 +1,223 @@
from enum import Enum
from shutil import copy2, rmtree
from packaging import version
from vollerei.abc.patcher import PatcherABC
from vollerei.common import telemetry
from vollerei.exceptions.game import GameNotInstalledError
from vollerei.exceptions.patcher import (
VersionNotSupportedError,
PatcherError,
PatchUpdateError,
)
from vollerei.game.launcher.manager import Game, GameChannel
from vollerei.utils import download_and_extract, Git, Xdelta3
from vollerei.paths import tools_data_path
from vollerei.hsr.constants import ASTRA_REPO, JADEITE_REPO
class PatchType(Enum):
"""
Patch type
Astra: The old patch which patch the game directly (not recommended).
Jadeite: The new patch which patch the game in memory by DLL injection.
"""
Astra = 0
Jadeite = 1
class Patcher(PatcherABC):
"""
Patch helper for HSR and HI3.
By default this will use Jadeite as it is maintained and more stable.
"""
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:
"""
Patch type, can be either Astra or Jadeite
"""
return self._patch_type
@patch_type.setter
def patch_type(self, value: PatchType):
self._patch_type = value
def _update_astra(self):
self._git.pull_or_clone(ASTRA_REPO, self._astra)
def _update_jadeite(self):
release_info = self._git.get_latest_release(JADEITE_REPO)
file = self._git.get_latest_release_dl(release_info)[0]
file_version = release_info["tag_name"][1:] # Remove "v" prefix
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 version.parse(file_version) <= version.parse(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):
"""
Update the patch
"""
try:
match self._patch_type:
case PatchType.Astra:
self._update_astra()
case PatchType.Jadeite:
self._update_jadeite()
except Exception as e:
raise PatchUpdateError("Failed to update patch.") from e
def _patch_astra(self, game: Game):
if game.get_version() != (1, 0, 5):
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 and patch
for file in ["UnityPlayer.dll", "StarRailBase.dll"]:
game.path.joinpath(file).rename(game.path.joinpath(f"{file}.bak"))
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):
"""
"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 _unpatch_astra(self, game: Game):
if game.get_version() != (1, 0, 5):
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"
# Restore
for file in ["UnityPlayer.dll", "StarRailBase.dll"]:
if game.path.joinpath(f"{file}.bak").exists():
game.path.joinpath(file).unlink()
game.path.joinpath(f"{file}.bak").rename(game.path.joinpath(file))
# Remove files
for file in self._astra.joinpath(f"{file_type}/files/").rglob("*"):
if file.suffix == ".bat":
continue
file_rel = file.relative_to(self._astra.joinpath(f"{file_type}/files/"))
game_path = game.path.joinpath(file_rel)
if game_path.is_file():
game_path.unlink()
elif game_path.is_dir():
try:
game_path.rmdir()
except OSError:
pass
def _unpatch_jadeite(self):
rmtree(self._jadeite, ignore_errors=True)
def patch_game(self, game: Game):
"""
Patch the game
If you use Jadeite (by default), this will just download Jadeite files
and won't actually patch the game because Jadeite will do that automatically.
Args:
game (Game): The game to patch
"""
if not game.is_installed():
raise PatcherError(GameNotInstalledError("Game is not installed"))
match self._patch_type:
case PatchType.Astra:
self._patch_astra(game)
case PatchType.Jadeite:
return self._patch_jadeite()
def unpatch_game(self, game: Game):
"""
Unpatch the game
If you use Jadeite (by default), this will just delete Jadeite files.
Note that Honkai Impact 3rd uses Jadeite too, so executing this will
delete the files needed by both games.
Args:
game (Game): The game to unpatch
"""
if not game.is_installed():
raise PatcherError(GameNotInstalledError("Game is not installed"))
match self._patch_type:
case PatchType.Astra:
self._unpatch_astra(game)
case PatchType.Jadeite:
self._unpatch_jadeite()
def check_telemetry(self) -> list[str]:
"""
Check if telemetry servers are accessible by the user
Returns:
list[str]: A list of telemetry servers that are accessible
"""
return telemetry.check_telemetry()
def block_telemetry(self, telemetry_list: list[str] = None):
"""
Block the telemetry servers
If telemetry_list is not provided, it will be checked automatically.
Args:
telemetry_list (list[str], optional): A list of telemetry servers to block.
"""
telemetry.block_telemetry(telemetry_list)

View File

@ -0,0 +1,66 @@
from vollerei.abc.launcher.game import GameABC
def get_version(game: GameABC) -> 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/zzz/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 = game.data_folder().joinpath("globalgamemanagers")
if not data_file.exists():
return game.get_version_config()
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
version_bytes: list[list[bytes]] = [[], [], []]
version_ptr = 0
correct = True
try:
with data_file.open("rb") as f:
f.seek(4000)
for byte in f.read(10000):
match byte:
case 0:
if (
correct
and len(version_bytes[0]) > 0
and len(version_bytes[1]) > 0
and len(version_bytes[2]) > 0
):
found_version = tuple(
bytes_to_int(i) for i in version_bytes
)
return found_version
version_bytes = [[], [], []]
version_ptr = 0
correct = True
case b".":
version_ptr += 1
if version_ptr > 2:
correct = False
case _:
if correct and byte in b"0123456789":
version_bytes[version_ptr].append(byte)
else:
correct = False
except Exception:
pass
# Fallback to config.ini
return game.get_version_config()

View File

@ -0,0 +1,5 @@
# Re-exports
from vollerei.genshin.launcher import Game
__all__ = ["Game"]

View File

@ -0,0 +1,5 @@
# Re-exports
from vollerei.genshin.launcher.game import Game
__all__ = ["Game"]

View File

@ -0,0 +1,20 @@
from vollerei.common.api import get_game_packages, resource
from vollerei.common.enums import GameChannel
def get_game_package(channel: GameChannel = GameChannel.Overseas) -> resource.GameInfo:
"""
Get game package information from the launcher API.
Default channel is overseas.
Args:
channel: Game channel to get the resource information from.
Returns:
Resource: Game resource information.
"""
game_packages = get_game_packages(channel=channel)
for package in game_packages:
if "hk4e" in package.game.biz:
return package

View File

@ -0,0 +1,12 @@
from os import PathLike
from vollerei.game.launcher.manager import Game as CommonGame
from vollerei.common.enums import GameType
class Game(CommonGame):
"""
Manages the game installation
"""
def __init__(self, path: PathLike = None, cache_path: PathLike = None):
super().__init__(GameType.Genshin, path, cache_path)

View File

@ -1,24 +0,0 @@
class LAUNCHER_API:
"""Launcher API constants."""
RESOURCE_PATH: str = "mdk/launcher/api/resource"
OS: dict = {
"url": "https://hkrpg-launcher-static.hoyoverse.com/hkrpg_global/",
"params": {
"channel_id": 1,
"key": "vplOVX8Vn7cwG8yb",
"launcher_id": 35,
},
}
ASIA: dict = {}
CN: dict = {
"url": "https://api-launcher.mihoyo.com/hkrpg_cn/mdk/launcher/api/resource",
"params": {
"channel_id": 1,
"key": "6KcVuOkbcqjJomjZ",
"launcher_id": 33,
},
}
LATEST_VERSION = (7, 2, 0)

View File

@ -1,9 +0,0 @@
from enum import Enum
class GameChannel(Enum):
Global = 0
Asia = 1
Taiwan = 2
Korea = 3
China = 4

View File

@ -1,6 +1,6 @@
# Re-exports # Re-exports
from vollerei.hsr.patcher import Patcher, PatchType from vollerei.hsr.patcher import Patcher, PatchType
from vollerei.hsr.launcher import Game, GameChannel from vollerei.hsr.launcher import Game
__all__ = ["Patcher", "PatchType", "Game", "GameChannel"] __all__ = ["Patcher", "PatchType", "Game"]

View File

@ -1,38 +1 @@
class LAUNCHER_API: from vollerei.game.hsr.constants import * # noqa: F403 because we just want to re-export
"""Launcher API constants."""
RESOURCE_PATH: str = "mdk/launcher/api/resource"
OS: dict = {
"url": "https://hkrpg-launcher-static.hoyoverse.com/hkrpg_global/",
"params": {
"channel_id": 1,
"key": "vplOVX8Vn7cwG8yb",
"launcher_id": 35,
},
}
CN: dict = {
"url": "https://api-launcher.mihoyo.com/hkrpg_cn/mdk/launcher/api/resource",
"params": {
"channel_id": 1,
"key": "6KcVuOkbcqjJomjZ",
"launcher_id": 33,
},
}
LATEST_VERSION = (1, 6, 0)
MD5SUMS = {
"1.0.5": {
"cn": {
"StarRailBase.dll": "66c42871ce82456967d004ccb2d7cf77",
"UnityPlayer.dll": "0c866c44bb3752031a8c12ffe935b26f",
},
"os": {
"StarRailBase.dll": "8aa3790aafa3dd176678392f3f93f435",
"UnityPlayer.dll": "f17b9b7f9b8c9cbd211bdff7771a80c2",
},
}
}
# Patches
ASTRA_REPO = "https://notabug.org/mkrsym1/astra"
JADEITE_REPO = "https://codeberg.org/mkrsym1/jadeite/"

View File

@ -1,5 +1,5 @@
# Re-exports # Re-exports
from vollerei.hsr.launcher.game import Game, GameChannel from vollerei.hsr.launcher.game import Game
__all__ = ["Game", "GameChannel"] __all__ = ["Game"]

View File

@ -1,13 +1,10 @@
import requests from vollerei.common.api import get_game_packages, resource
from vollerei.common.enums import GameChannel
from vollerei.common.api import Resource
from vollerei.hsr.constants import LAUNCHER_API
from vollerei.hsr.launcher.enums import GameChannel
def get_resource(channel: GameChannel = GameChannel.Overseas) -> Resource: def get_game_package(channel: GameChannel = GameChannel.Overseas) -> resource.GameInfo:
""" """
Get game resource information from the launcher API. Get game package information from the launcher API.
Default channel is overseas. Default channel is overseas.
@ -17,15 +14,7 @@ def get_resource(channel: GameChannel = GameChannel.Overseas) -> Resource:
Returns: Returns:
Resource: Game resource information. Resource: Game resource information.
""" """
resource_path: dict = None game_packages = get_game_packages(channel=channel)
match channel: for package in game_packages:
case GameChannel.Overseas: if "hkrpg" in package.game.biz:
resource_path = LAUNCHER_API.OS return package
case GameChannel.China:
resource_path = LAUNCHER_API.CN
return Resource.from_dict(
requests.get(
resource_path["url"] + LAUNCHER_API.RESOURCE_PATH,
params=resource_path["params"],
).json()["data"]
)

View File

@ -1,6 +0,0 @@
from enum import Enum
class GameChannel(Enum):
Overseas = 0
China = 1

View File

@ -1,27 +1,9 @@
from configparser import ConfigParser
from hashlib import md5
from io import IOBase
from os import PathLike from os import PathLike
from pathlib import Path from vollerei.game.launcher.manager import Game as CommonGame
from shutil import move, copyfile from vollerei.common.enums import GameType
from vollerei.abc.launcher.game import GameABC
from vollerei.common import ConfigFile, functions
from vollerei.common.api import resource
from vollerei.common.enums import VoicePackLanguage
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): class Game(CommonGame):
""" """
Manages the game installation Manages the game installation
@ -31,454 +13,4 @@ class Game(GameABC):
""" """
def __init__(self, path: PathLike = None, cache_path: PathLike = None): def __init__(self, path: PathLike = None, cache_path: PathLike = None):
self._path: Path | None = Path(path) if path else None super().__init__(GameType.HSR, path, cache_path)
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_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 GameChannel.Overseas 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_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 cfg_file.exists():
cfg = ConfigFile(cfg_file)
cfg.set("General", "game_version", self.get_version_str())
cfg.save()
else:
cfg = ConfigParser()
cfg.read_dict(
{
"General": {
"channel": 1,
"cps": "hoyoverse_PC",
"game_version": self.get_version_str(),
"sub_channel": 1,
"plugin_2_version": "0.0.1",
}
}
)
cfg.write(cfg_file.open("w"))
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.
Because this method uses `get_version()`, you should read the docs of
that method too.
Returns:
str: The version as a string.
"""
return ".".join(str(i) for i in self.get_version())
def get_installed_voicepacks(self) -> list[VoicePackLanguage]:
"""
Gets the installed voicepacks.
Returns:
list[VoicePackLanguage]: A list of installed voicepacks.
"""
if not self.is_installed():
raise GameNotInstalledError("Game is not installed.")
voicepacks = []
for child in (
self.data_folder()
.joinpath("Persistent/Audio/AudioPackage/Windows/")
.iterdir()
):
if child.is_dir():
try:
voicepacks.append(VoicePackLanguage[child.name])
except ValueError:
pass
return voicepacks
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.
"""
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.
Args:
pre_download (bool): Whether to get the pre-download version.
Defaults to False.
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(str(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,
game_info: resource.Game = 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.
"""
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.")
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.")
# .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
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 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:
"""
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.
Defaults to True.
"""
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)
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, and after that
execute `set_version_config()` to set the game version.
Args:
update_info (Diff, optional): The update information. Defaults to None.
auto_repair (bool, optional): Whether to repair the file if it's broken.
Defaults to True.
"""
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.")
# Base game update
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)
# Get installed voicepacks
installed_voicepacks = self.get_installed_voicepacks()
# Voicepack update
for remote_voicepack in update_info.voice_packs:
if remote_voicepack.language not in installed_voicepacks:
continue
# Voicepack is installed, update it
archive_file = self.cache.joinpath(remote_voicepack.name)
download(remote_voicepack.path, archive_file)
self.apply_update_archive(
archive_file=archive_file, auto_repair=auto_repair
)
self.set_version_config()

View File

@ -3,13 +3,14 @@ from shutil import copy2, rmtree
from packaging import version from packaging import version
from vollerei.abc.patcher import PatcherABC from vollerei.abc.patcher import PatcherABC
from vollerei.common import telemetry from vollerei.common import telemetry
from vollerei.common.enums import GameChannel
from vollerei.exceptions.game import GameNotInstalledError from vollerei.exceptions.game import GameNotInstalledError
from vollerei.exceptions.patcher import ( from vollerei.exceptions.patcher import (
VersionNotSupportedError, VersionNotSupportedError,
PatcherError, PatcherError,
PatchUpdateError, PatchUpdateError,
) )
from vollerei.hsr.launcher.game import Game, GameChannel from vollerei.hsr.launcher.game import Game
from vollerei.utils import download_and_extract, Git, Xdelta3 from vollerei.utils import download_and_extract, Git, Xdelta3
from vollerei.paths import tools_data_path from vollerei.paths import tools_data_path
from vollerei.hsr.constants import ASTRA_REPO, JADEITE_REPO from vollerei.hsr.constants import ASTRA_REPO, JADEITE_REPO
@ -23,8 +24,8 @@ class PatchType(Enum):
Jadeite: The new patch which patch the game in memory by DLL injection. Jadeite: The new patch which patch the game in memory by DLL injection.
""" """
Astra: int = 0 Astra = 0
Jadeite: int = 1 Jadeite = 1
class Patcher(PatcherABC): class Patcher(PatcherABC):

View File

@ -9,8 +9,8 @@ class Paths:
""" """
base_paths = PlatformDirs("vollerei", "tretrauit", roaming=True) base_paths = PlatformDirs("vollerei", "tretrauit", roaming=True)
cache_path = base_paths.site_cache_path cache_path = base_paths.user_cache_path
data_path = base_paths.site_data_path data_path = base_paths.user_data_path
tools_data_path = data_path.joinpath("tools") tools_data_path = data_path.joinpath("tools")
tools_cache_path = cache_path.joinpath("tools") tools_cache_path = cache_path.joinpath("tools")
launcher_cache_path = cache_path.joinpath("launcher") launcher_cache_path = cache_path.joinpath("launcher")

View File

@ -1,3 +1,4 @@
from os import PathLike
import platform import platform
import subprocess import subprocess
from zipfile import ZipFile from zipfile import ZipFile
@ -34,6 +35,8 @@ class HDiffPatch:
return "windows32" return "windows32"
case "x86_64": case "x86_64":
return "windows64" return "windows64"
case "AMD64":
return "windows64"
case "arm": case "arm":
return "windows_arm32" return "windows_arm32"
case "arm64": case "arm64":
@ -84,9 +87,11 @@ class HDiffPatch:
def hpatchz(self) -> str | None: def hpatchz(self) -> str | None:
return self._get_binary("hpatchz") return self._get_binary("hpatchz")
def patch_file(self, in_file, out_file, patch_file): def patch_file(self, in_file: PathLike, out_file: PathLike, patch_file: PathLike):
try: try:
subprocess.check_call([self.hpatchz(), "-f", in_file, patch_file, out_file]) subprocess.check_call(
[self.hpatchz(), "-f", str(in_file), str(patch_file), str(out_file)]
)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise HPatchZPatchError("Patch error") from e raise HPatchZPatchError("Patch error") from e

5
vollerei/zzz/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# Re-exports
from vollerei.zzz.launcher import Game
__all__ = ["Game"]

View File

@ -0,0 +1,5 @@
# Re-exports
from vollerei.zzz.launcher.game import Game
__all__ = ["Game"]

View File

@ -0,0 +1,20 @@
from vollerei.common.api import get_game_packages, resource
from vollerei.common.enums import GameChannel
def get_game_package(channel: GameChannel = GameChannel.Overseas) -> resource.GameInfo:
"""
Get game package information from the launcher API.
Default channel is overseas.
Args:
channel: Game channel to get the resource information from.
Returns:
Resource: Game resource information.
"""
game_packages = get_game_packages(channel=channel)
for package in game_packages:
if "nap" in package.game.biz:
return package

View File

@ -0,0 +1,16 @@
from os import PathLike
from vollerei.game.launcher.manager import Game as CommonGame
from vollerei.common.enums import GameType
class Game(CommonGame):
"""
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):
super().__init__(GameType.ZZZ, path, cache_path)

View File