updater.py 13 KB

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