updater.py 12 KB

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