updater.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. #!/usr/bin/env python3
  2. """
  3. Functional description
  4. ----------------------
  5. chunks
  6. zstd-compressed sections of files for patching
  7. manifest
  8. Provides information for all chunks
  9. name (= URL), patch offset and checksum
  10. getBuild
  11. JSON file that provides information about the available game and voiceover pack files
  12. Provides manifests and the base URL to download chunks from
  13. TODO
  14. ----
  15. High priority
  16. New game installation
  17. Which URL?
  18. Where is 'deletefiles.txt' ?
  19. voiceover-packs
  20. Medium priority
  21. Make chunk names identical to the official launcher
  22. Low priority
  23. Parallelization for file downloads and patching
  24. """
  25. from __future__ import annotations
  26. import argparse
  27. import hashlib # md5
  28. import json
  29. import pathlib
  30. import re # Regular Expressions
  31. import shutil # rmtree
  32. import time
  33. import urllib.request as request # downloads
  34. from typing import TYPE_CHECKING
  35. import zstandard # archive unpacking
  36. from google.protobuf.json_format import MessageToJson
  37. import manifest_pb2 # generated
  38. if TYPE_CHECKING:
  39. from os import PathLike
  40. SCRIPTDIR = pathlib.Path(__file__).resolve().parent
  41. OPT: Options
  42. # ------------------- CLI interface
  43. class Options(argparse.Namespace):
  44. gamedir: pathlib.Path
  45. tempdir: pathlib.Path
  46. force_use_cache: bool = False
  47. predownload: bool = False
  48. do_install: bool = False
  49. do_update: bool = False
  50. list_zips: bool = False
  51. # This script does currently not write anything to the game installation directory
  52. def parse_args() -> Options:
  53. parser = argparse.ArgumentParser(description="Game install and update client (WIP)")
  54. parser.add_argument("gamedir", type=pathlib.Path)
  55. parser.add_argument("--tempdir", dest="tempdir", type=pathlib.Path, default=SCRIPTDIR / "tmp")
  56. parser.add_argument("--force-use-cache", dest="force_use_cache", action="store_true")
  57. parser.add_argument("--predownload", action="store_true")
  58. parser.add_argument("--install", dest="do_install", action="store_true")
  59. parser.add_argument("--update", dest="do_update", action="store_true")
  60. parser.add_argument("--list-zips", dest="list_zips", action="store_true")
  61. return parser.parse_args(namespace=Options())
  62. def main():
  63. global OPT
  64. OPT = parse_args()
  65. cli = SophonClient()
  66. cli.initialize()
  67. cli.retrieve_API_keys()
  68. if OPT.list_zips:
  69. assert cli.packages_json is not None, "Not available."
  70. def dump_single(comment, section):
  71. print(f"Main game: {comment} for {section['version']}")
  72. for v in section["game_pkgs"]:
  73. print("\t " + v["url"])
  74. print("Voiceover packs:")
  75. for v in section["audio_pkgs"]:
  76. print(f"\t {v['language']} : " + v["url"])
  77. print("")
  78. print("List of available archives")
  79. print("-" * 26)
  80. js = cli.packages_json
  81. dump_single("new install", js["major"])
  82. for p in js["patches"]:
  83. dump_single("update", p)
  84. exit(0)
  85. assert False, "In development."
  86. #cli.cleanup_temp()
  87. cli.load_manifest("game")
  88. v = cli.find_chunks_by_file_name("pkg_version")
  89. if v:
  90. cli.download_and_patch_file(v)
  91. # ------------------- Utilities
  92. def tempdir(*args: str | PathLike[str]) -> pathlib.Path:
  93. return OPT.tempdir.joinpath(*args)
  94. def gamedir(*args: str | PathLike[str]) -> pathlib.Path:
  95. return OPT.gamedir.joinpath(*args)
  96. def debuglog(*args):
  97. print("DEBUG ", *args)
  98. def infolog(*args):
  99. print("INFO ", *args)
  100. def warnlog(*args):
  101. print("WARN ", *args)
  102. # -------------------
  103. class SophonClient:
  104. installed_ver: None # "major.minor.patch" or "new" for new installations
  105. rel_type: str | None = None # os / cn
  106. gamedatadir: pathlib.Path | None = None # absolute path to *_Data
  107. branch: str # main / pre_download
  108. branches_json = None # package_id, password, tag
  109. packages_json = None # zip archive listing
  110. getBuild_json = None # json object of the entire getBuild json
  111. manifest: manifest_pb2.Manifest | None = None # manifest_pb2
  112. category_json: None # json object of currently selected category
  113. def initialize(self):
  114. if OPT.do_install:
  115. OPT.gamedir.mkdir(exist_ok=True)
  116. # must be empty (allow config.ini)
  117. assert len(list(OPT.gamedir.glob("*"))) < 2, "The specified path is not empty"
  118. elif not OPT.gamedir.is_dir():
  119. print("Script usage: python3 updater.py /path/to/game/dir")
  120. exit(1)
  121. self.branch = "pre_download" if OPT.predownload else "main"
  122. infolog(f"Selected branch '{self.branch}'")
  123. OPT.tempdir.mkdir(exist_ok=True)
  124. # Autodetection
  125. if OPT.do_install:
  126. self._initialize_install()
  127. else:
  128. self._initialize_update()
  129. def _initialize_install(self):
  130. assert False, "TODO"
  131. # Prompt to ask for the desired game version
  132. pass
  133. def _initialize_update(self):
  134. """
  135. Find out what kind of installation we need to update
  136. """
  137. # Absolute path to the game data directory
  138. self.gamedatadir = next(OPT.gamedir.glob("*_Data"), None)
  139. assert self.gamedatadir, "Cannot determine game data dir"
  140. if gamedir("GenshinImpact.exe").is_file():
  141. self.rel_type = "os"
  142. elif gamedir("YuanShen.exe").is_file():
  143. if self.gamedatadir.joinpath("Plugins", "PCGameSDK.dll").is_file():
  144. self.rel_type = "bb"
  145. else:
  146. self.rel_type = "cn"
  147. assert isinstance(self.rel_type, str), "Failed to detect release type"
  148. infolog(f"Release type: {self.rel_type}")
  149. # Retrieve the installed game version
  150. if True:
  151. fullname = gamedir(self.gamedatadir, "globalgamemanagers")
  152. assert fullname.is_file(), "Game install is incomplete!"
  153. contents = fullname.read_bytes()
  154. ver = re.findall(br"\0(\d+\.\d+\.\d+)_\d+_\d+\0", contents)
  155. assert len(ver) == 1, "Broken script or corrupted game installation"
  156. self.installed_ver = ver[0].decode("utf-8")
  157. infolog(f"Installed game version: {self.installed_ver}")
  158. # Compare game version with what's contained in "config.ini"
  159. self.check_config_ini()
  160. def check_config_ini(self):
  161. """
  162. Internal function
  163. """
  164. fullname = gamedir("config.ini")
  165. if not fullname.is_file():
  166. return
  167. contents = fullname.read_text()
  168. ver = re.findall(r"game_version=(\d+\.\d+\.\d+)", contents)
  169. if len(ver) != 1:
  170. warnlog("config.ini is incomplete or corrupt")
  171. return
  172. infolog(f"config.ini: Game version {ver[0]}")
  173. if ver[0] != self.installed_ver:
  174. warnlog("config.ini and the actual installed game version differ!")
  175. def cleanup_temp(self):
  176. # DANGER
  177. assert False
  178. shutil.rmtree(OPT.tempdir)
  179. def load_or_download_file(self, fname, url):
  180. """
  181. fname: file name without path prefix
  182. url: str or function ptr to retrieve the URL
  183. Returns: File handle
  184. """
  185. fullname = tempdir(fname)
  186. do_download = True
  187. if fullname.is_file():
  188. # keep cached for 24 hours
  189. do_download = time.time() - fullname.stat().st_mtime > (24 * 3600)
  190. if OPT.force_use_cache:
  191. do_download = False
  192. if do_download:
  193. # Check whether the file is still up-to-date
  194. if callable(url):
  195. url = url()
  196. OPT.tempdir.mkdir(exist_ok=True)
  197. request.urlretrieve(url, fullname)
  198. debuglog(f"Got file '{fname}' (new)")
  199. else:
  200. debuglog(f"Got file '{fname}' (cached)")
  201. return fullname
  202. def load_or_download_json(self, fname, url):
  203. path = self.load_or_download_file(fname, url)
  204. with path.open("rb") as fh:
  205. js = json.load(fh)
  206. ret = js["retcode"]
  207. assert ret == 0, (f"Failed to retrieve '{fname}': " +
  208. f"server returned status code {ret} ({js['message']})")
  209. return js["data"]
  210. def retrieve_API_keys(self):
  211. """
  212. Retrieves passkeys for authentication to download URLs
  213. Depends on "initialize_*".
  214. """
  215. assert isinstance(self.rel_type, str), "Missing initialize"
  216. base_url = None
  217. tail = None
  218. if self.rel_type == "os":
  219. # Up-to-date as of 2024-06-15 (4.7.0)
  220. game_ids = "gopR6Cufr3"
  221. launcher_id = "VYTpXlbWo8"
  222. tail = f"game_ids[]={game_ids}&launcher_id={launcher_id}"
  223. base_url = "https://sg-hyp-api.hoy" + "overse.com/hyp/hyp-connect/api"
  224. assert self.rel_type == "os", "CN/BB yet not implemented" # TODO
  225. if not self.branches_json:
  226. # MANDATORY. JSON with package_id, password and tag(s)
  227. js = self.load_or_download_json("getGameBranches.json", f"{base_url}/getGameBranches?{tail}")
  228. # Array length corresponds to the amount of "game_ids" requested.
  229. self.branches_json = js["game_branches"][0][self.branch]
  230. assert self.branches_json is not None, "Cannot find API keys for the selected branch."
  231. ver = self.branches_json["tag"]
  232. infolog(f"Sophon provides game version {ver}")
  233. if False: # TODO
  234. # JSON with game paths for voiceover packs, logs, screenshots
  235. self.load_or_download_file("getGameConfigs.json", f"{base_url}/getGameConfigs?{tail}")
  236. if False: # TODO
  237. # JSON with SDK files (BiliBili ?)
  238. channel = 1
  239. sub_channel = 0
  240. self.load_or_download_file("getGameChannelSDKs.json",
  241. f"{base_url}/getGameChannelSDKs?channel={channel}&{tail}&sub_channel={sub_channel}")
  242. if OPT.do_update or OPT.do_install:
  243. # zip downloads (successor of the /resource JSON file)
  244. js = self.load_or_download_json("getGamePackages.json", f"{base_url}/getGamePackages?{tail}")
  245. self.packages_json = js["game_packages"][0][self.branch]
  246. if self.packages_json is None:
  247. infolog("No game packages (zip) available for the selected branch.")
  248. else:
  249. count = 1
  250. if isinstance(self.packages_json, list):
  251. count = len(self.packages_json)
  252. infolog(f"Available game packages (zip): {count}")
  253. def make_getBuild_url(self):
  254. """
  255. Compose the URL for the main JSON file for chunk-based downloads (sophon)
  256. Returns: URL
  257. """
  258. if not self.branches_json:
  259. self.retrieve_API_keys()
  260. url = None
  261. if self.rel_type == "os":
  262. url = "sg-public-api.ho" + "yoverse.com"
  263. elif self.rel_type == "cn":
  264. url = "api-takumi.mih" + "oyo.com"
  265. assert not (url is None), f"Unhandled release type {self.rel_type}"
  266. url = (
  267. "https://" + url + "/downloader/sophon_chunk/api/getBuild"
  268. + "?branch=" + self.branches_json["branch"]
  269. + "&package_id=" + self.branches_json["package_id"]
  270. + "&password=" + self.branches_json["password"]
  271. )
  272. infolog("Created getBuild JSON URL")
  273. return url
  274. def load_getBuild_json(self):
  275. """
  276. Loads the main JSON for manifest and chunk information
  277. """
  278. path = self.load_or_download_file("getBuild.json", self.make_getBuild_url)
  279. with path.open("rb") as fh:
  280. self.getBuild_json = json.load(fh)
  281. infolog("Loaded getBuild JSON")
  282. def load_manifest(self, cat_name):
  283. """
  284. Loads the specified manifest protobuf
  285. cat_name: "game", "en-us", "zh-cn", "ja-jp", "ko-kr"
  286. """
  287. if not self.getBuild_json:
  288. self.load_getBuild_json()
  289. jd = self.getBuild_json["data"]
  290. infolog(f"Server provides game version {jd['tag']}")
  291. # Find matching category
  292. category = None
  293. for jdm in jd["manifests"]:
  294. if jdm["matching_field"] == cat_name:
  295. category = jdm
  296. break
  297. assert not (category is None), f"Cannot find the specified field '{cat_name}'"
  298. infolog(f"Found category {cat_name}")
  299. self.category_json = category
  300. # Download and decompress manifest protobuf
  301. fname_raw = category["manifest"]["id"]
  302. url = category["manifest_download"]["url_prefix"] + "/" + category["manifest"]["id"]
  303. zstd_path = self.load_or_download_file(fname_raw + ".zstd", url)
  304. with zstd_path.open('br') as zfh:
  305. reader = zstandard.ZstdDecompressor().stream_reader(zfh)
  306. pb = manifest_pb2.Manifest()
  307. pb.ParseFromString(reader.read())
  308. nfiles = len(pb.files)
  309. infolog(f"Decompressed manifest protobuf ({nfiles} files)")
  310. json_fname = tempdir(fname_raw + ".json")
  311. if not json_fname.is_file():
  312. with json_fname.open("w+") as jfh:
  313. json.dump(json.loads(MessageToJson(pb)), jfh)
  314. infolog("Exported protobuf to JSON file")
  315. self.manifest = pb
  316. def find_chunks_by_file_name(self, file_name):
  317. """
  318. Searches a specific file name in the manifest
  319. Returns: FileInfo or None
  320. """
  321. assert isinstance(file_name, str)
  322. for v in self.manifest.files:
  323. if v.filename == file_name:
  324. return v
  325. warnlog(f"Cannot find chunks for file: {file_name}")
  326. return None
  327. def download_and_patch_file(self, file_info):
  328. """
  329. Downloads the chunks and patches a file
  330. file_info: FileInfo, one of the manifest.files[] objects
  331. """
  332. if file_info.flags == 64:
  333. # Created as soon a file is put inside
  334. infolog(f"Skipping directory entry: {file_info.filename}")
  335. return
  336. assert (file_info.flags == 0), f"Unknown flags {file_info.flags} for '{file_info.filename}'"
  337. chunk_url_prefix = self.category_json["chunk_download"]["url_prefix"]
  338. infolog(
  339. "Chunk downloader:\n"
  340. f"\t File name: {file_info.filename}\n"
  341. f"\t Chunk count: {len(file_info.chunks)}\n"
  342. f"\t File size: {int(file_info.size / (1024 * 1024 / 10) + 0.5) / 10} MiB"
  343. )
  344. # Disallow writing to unpredictable locations
  345. assert (".." not in str(file_info.filename)), "Security alert"
  346. assert (str(file_info.filename)[0] != '/'), "Security alert"
  347. # Create directory structure and patch file into the temporary directory
  348. parent_dir = pathlib.Path(file_info.filename).parent
  349. tempdir(parent_dir).mkdir(parents=True, exist_ok=True)
  350. tmp_file = tempdir(file_info.filename)
  351. shutil.copyfile(gamedir(file_info.filename), tmp_file)
  352. with tmp_file.open("wb") as fh: # no truncate!
  353. # Download all chunks
  354. n = 0
  355. for chunk in file_info.chunks:
  356. n += 1
  357. cfname = tempdir(chunk.chunk_id)
  358. try:
  359. if cfname.stat().st_size == chunk.compressed_size:
  360. continue # TODO: do a proper hash check
  361. except FileNotFoundError:
  362. pass # Already downloaded
  363. request.urlretrieve(chunk_url_prefix + "/" + chunk.chunk_id, cfname)
  364. print(f"\t * Downloaded chunk {n} / {len(file_info.chunks)}")
  365. # Apply all chunks
  366. n = 0
  367. for chunk in file_info.chunks:
  368. n += 1
  369. cfname = tempdir(chunk.chunk_id)
  370. # Attempt to patch
  371. with cfname.open("rb") as zfh:
  372. reader = zstandard.ZstdDecompressor().stream_reader(zfh)
  373. fh.seek(chunk.offset)
  374. fh.write(reader.read())
  375. print(f"\t * Patched {n} / {len(file_info.chunks)}")
  376. # Verify file integrity
  377. md5 = hashlib.md5(tmp_file.read_bytes()).hexdigest()
  378. if file_info.md5 == md5:
  379. print("\t * Patching succeeded. md5 matches.")
  380. else:
  381. print(f"\t * Hash mismatch after patching. File is corrupt: {file_info.filename}")
  382. print("")
  383. if __name__ == '__main__':
  384. main()