sophon_api.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327
  1. # Sophon chunk/diff installer and updater implementation
  2. # SPDX-License-Identifier: MIT
  3. # Copyright (C) 2025 Krock <mk939@ymail.com>
  4. """
  5. Rough API documentation
  6. -----------------------
  7. SHA/getGameConfigs?...
  8. Game paths (audio/voiceover, screenshots, logs, crash dumps) for all HoYo games
  9. SHA/getAllGameBasicInfo?...
  10. Launcher background data for the specified game
  11. SHA/getGames?...
  12. Launcher images and links for all HoYo games
  13. SHA/getGameContent?...
  14. Event preview data for the specified game
  15. SGP/getLatestRelease?...
  16. Launcher update information
  17. SDA/getPatchBuild (POST)
  18. List of manifests information (same as getBuild)
  19. Seriously guys, why don't you provide the chunk URL, diff URL and the two manifests in the same file?
  20. Functional description
  21. ----------------------
  22. 1 ) getBuild
  23. JSON file that provides information about the available game and voiceover pack files
  24. Provides manifests and the base URL to download chunks from
  25. 2 ) manifest
  26. Provides information for all chunks or ldiff files
  27. 3a) chunks
  28. zstd-compressed sections of files for installing from scratch (or new ones)
  29. 3b) diffs
  30. hdiff files to patch installed game files
  31. TODO
  32. ----
  33. Low priority
  34. Parallelization for file downloads and patching
  35. Apply patches for non-existent files (apparently the official launcher can do that)
  36. Hints for developers:
  37. 1. Investigate the JSON files downloaded to `tmp/` -> variable `EXPORT_JSON_FILES`
  38. """
  39. from __future__ import annotations
  40. import argparse
  41. import hashlib # md5
  42. import io # TextIOWrapper
  43. import json
  44. import pathlib
  45. import re # Regular Expressions
  46. import shutil # rmtree
  47. import subprocess # for hpatchz (ldiff)
  48. import sys # stdout
  49. import tempfile # patch extraction
  50. import time
  51. import urllib.error # exception handling
  52. import urllib.request as request # downloads
  53. from typing import TYPE_CHECKING
  54. import zstandard # archive unpacking
  55. from google.protobuf.json_format import MessageToJson
  56. import manifest_pb2 # generated
  57. import manifest_ldiff_pb2 # generated
  58. if TYPE_CHECKING:
  59. from os import PathLike
  60. SCRIPTDIR = pathlib.Path(__file__).resolve().parent
  61. # Needed for ldiff
  62. HPATCHZ_APP = SCRIPTDIR / "HDiffPatch/hpatchz"
  63. assert HPATCHZ_APP.is_file(), f"{HPATCHZ_APP.resolve()} not found."
  64. # Not needed. Only helpful for development purposes.
  65. EXPORT_JSON_FILES = True
  66. # ------------------- CLI options
  67. class Options(argparse.Namespace):
  68. gamedir: pathlib.Path | None = None
  69. tempdir: pathlib.Path | None = SCRIPTDIR / "tmp" # cache
  70. # where to place ldiff files and patched output files
  71. #outputdir: pathlib.Path | None = SCRIPTDIR / "tmp" / "out"
  72. force_use_cache: bool = False # True: disallow downloads, False: download if not cached
  73. predownload: bool = False
  74. install_reltype: str | None = None
  75. do_install: bool = False
  76. do_update: bool = False # True: ldiff, False: chunks
  77. repair_mode: str | None = None # "quick"|"reliable"|None
  78. dry_run: bool = False # True: prevents modifying game files
  79. disallow_download: bool = False # True: prevents media downloads
  80. # `True` ignores the "empty directory" requirement for installs and skips sanity checks for updates
  81. ignore_conditions: bool = False
  82. TESTING_FILE: str | None = None # if != None: only update/download the specified file
  83. # main() script only
  84. selected_lang_packs: str = ""
  85. # Cannot be overwritten by other scripts :(
  86. OPT = Options()
  87. # ------------------- Translate between voiceover pack names
  88. VOICEOVERS_LUT = {
  89. # "Friendly/short": {"short": "aa-bb", "friendly": "Longname"}
  90. "English(US)": {"short": "en-us"},
  91. "Japanese": {"short": "ja-jp"},
  92. "Korean": {"short": "ko-kr"},
  93. "Chinese": {"short": "zh-cn"}
  94. }
  95. if True:
  96. keys: list = list(VOICEOVERS_LUT.keys())
  97. for k in keys:
  98. v = VOICEOVERS_LUT[k]
  99. v["friendly"] = k
  100. # Add reverse lookup for the short version
  101. VOICEOVERS_LUT[v["short"]] = v
  102. # ------------------- Utilities
  103. def _handle_kwargs(kwargs):
  104. sys.stdout.write("\33[2K")
  105. def tempdir(*args: str | PathLike[str]) -> pathlib.Path:
  106. return OPT.tempdir.joinpath(*args)
  107. def gamedir(*args: str | PathLike[str]) -> pathlib.Path:
  108. return OPT.gamedir.joinpath(*args)
  109. def debuglog(*args, **kwargs):
  110. _handle_kwargs(kwargs)
  111. print("\033[37mDEBUG ", *args, "\033[0m", **kwargs)
  112. def infolog(*args, **kwargs):
  113. _handle_kwargs(kwargs)
  114. print("INFO ", *args, **kwargs)
  115. def warnlog(*args, **kwargs):
  116. _handle_kwargs(kwargs)
  117. print("\033[36mWARN ", *args, "\033[0m", **kwargs)
  118. def abortlog(*args, **kwargs):
  119. _handle_kwargs(kwargs)
  120. print("\033[31mERROR ", *args, "\033[0m", **kwargs)
  121. exit(1)
  122. def try_get_file_size(filename: pathlib.Path):
  123. """
  124. Returns -1 if the file was not found
  125. """
  126. try:
  127. return filename.stat().st_size
  128. except FileNotFoundError:
  129. return -1
  130. def filename_safety_check(filename):
  131. """
  132. Checks whether the path is relative AND within this tree
  133. This ensures that no files are written to unpredictable locations.
  134. """
  135. assert (".." not in str(filename)), f"Security alert! {filename}"
  136. assert (str(filename)[0] != '/'), f"Security alert! {filename}"
  137. def bytes_to_MiB(n: float):
  138. return int(n / (1024 * 1024 / 10) + 0.5) / 10
  139. def cmp_versions(lhs: list, rhs: list) -> int:
  140. """
  141. Returns [1 if lhs > rhs], [-1 if lhs < rhs], [0 if equal]
  142. """
  143. assert len(lhs) == len(rhs)
  144. for i in range(len(lhs)):
  145. if lhs[i] < rhs[i]:
  146. return -1
  147. if lhs[i] > rhs[i]:
  148. return 1
  149. return 0
  150. def hpatchz_patch_file(oldfile: pathlib.Path, dstfile: pathlib.Path, patchfile: pathlib.Path,
  151. p_offset: int, p_len: int, timeout: int = 50):
  152. """
  153. Patches a file, throws an exception upon failure
  154. One ldiff file may contain multiple patches, thus the offset
  155. Returns `True` on success, `False` on timeout
  156. """
  157. pfile_in = None # keep alive until functoin exit
  158. # Extract the relevant patch section
  159. # Note: This is also needed if `p_offset == 0`. Unlike other archiver programs or
  160. # libraries, hpatchz does not allow tailing data.
  161. pfile_in = patchfile.open("rb")
  162. pfile_in.seek(p_offset)
  163. pfile_out = tempfile.NamedTemporaryFile("wb")
  164. pfile_out.write(pfile_in.read(p_len))
  165. pfile_out.flush()
  166. proc = subprocess.Popen(
  167. # -f: overwrite the target (temporary) file
  168. [HPATCHZ_APP, "-f", oldfile, pfile_out.name, dstfile],
  169. stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  170. text=True
  171. )
  172. # Wait for the process to exit
  173. # This usually takes < 100 ms
  174. try:
  175. pout, perr = proc.communicate(timeout=timeout)
  176. except subprocess.TimeoutExpired:
  177. proc.terminate()
  178. pout, perr = proc.communicate()
  179. dstfile.unlink(True) # maybe stuck at writing
  180. warnlog(f"hpatchz timeout ({timeout} s) reached on file '{dstfile.name}'.")
  181. return False
  182. retcode = proc.returncode
  183. if retcode != 0 or perr != "":
  184. dstfile.unlink(True) # hpatchz may create 0 byte files on failure. Remove it.
  185. abortlog(f"Failed to patch file '{oldfile.name}' using '{patchfile.name}':"
  186. + f"\n\t Exit code: {retcode}"
  187. + "\n\t Message: " + perr)
  188. #debuglog("\n", pout)
  189. """
  190. Error Messages And Their Meaning
  191. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  192. oldFile dataSize <integer> != diffFile saved oldDataSize <integer> ERROR!
  193. Wrong diff file; it does not match the file "signature"
  194. open oldFile for read ERROR!
  195. Missing source file
  196. patch run ERROR!
  197. Patch file has an unexpected length
  198. """
  199. return True
  200. # -------------------
  201. class DownloadInfo:
  202. name: str | None = None # for logging
  203. getBuild_json = None # json object. 'get(Patch)Build' contents for URL information
  204. # Category-specific list of files and checksums
  205. manifest: manifest_pb2.Manifest | manifest_ldiff_pb2.DiffManifest | None = None
  206. category_json: None # json object. "game", or language pack information
  207. class SophonClient:
  208. installed_ver: None # "major.minor.patch" or "new" for new installations
  209. rel_type: str | None = None # os / cn / bb
  210. gamedatadir: str | None= None # "*_Data"
  211. branch: str # main / pre_download
  212. branches_json = None # package_id, password, tag
  213. # chunks: For files to download from scratch
  214. # diffs: For files to update by patching or removal
  215. di_chunks = DownloadInfo()
  216. di_diffs = DownloadInfo()
  217. new_files_to_download = set() # Update only. Relative file name
  218. ldiff_files_to_remove = set() # Update only. File name (no path)
  219. def initialize(self, opts: Options):
  220. global OPT
  221. OPT = opts
  222. self.di_chunks.name = "[chunks]"
  223. self.di_diffs.name = "[diffs]"
  224. if OPT.install_reltype:
  225. OPT.do_install = True
  226. self.rel_type = OPT.install_reltype
  227. if OPT.do_install + OPT.do_update + isinstance(OPT.repair_mode, str) > 1:
  228. abortlog("Do either install, update or repair!")
  229. assert OPT.gamedir != None, "Game directory not specified."
  230. self.branch = "pre_download" if OPT.predownload else "main"
  231. infolog(f"Selected branch '{self.branch}'")
  232. OPT.tempdir.mkdir(exist_ok=True)
  233. if OPT.dry_run:
  234. infolog("Simulation mode is enabled.")
  235. # Autodetection
  236. if OPT.do_install:
  237. self._initialize_install()
  238. if OPT.do_update or OPT.repair_mode:
  239. self._initialize_update()
  240. if not OPT.gamedir.is_dir():
  241. abortlog("Game directory does not exist.")
  242. def _initialize_install(self):
  243. self.installed_ver = None
  244. OPT.gamedir.mkdir(exist_ok=True)
  245. if not OPT.ignore_conditions:
  246. # must be empty (allow config.ini)
  247. assert len(list(OPT.gamedir.glob("*"))) < 2, "The specified install path is not empty"
  248. # Create "config.ini"
  249. templates = {
  250. "os": "[General]\r\nchannel=1\r\ncps=mihoyo\r\ngame_version=0.0.0\r\nsdk_version=\r\nsub_channel=0\r\n",
  251. "cn": "[General]\r\nchannel=1\r\ncps=mihoyo\r\ngame_version=0.0.0\r\nsdk_version=\r\nsub_channel=1\r\n",
  252. "bb": "[General]\r\nchannel=14\r\ncps=bilibili\r\ngame_version=0.0.0\r\nsdk_version=\r\nsub_channel=0\r\n"
  253. }
  254. assert templates[self.rel_type], "Unknown reltype"
  255. with gamedir("config.ini").open("w") as fh:
  256. fh.write(templates[self.rel_type])
  257. infolog("Created config.ini")
  258. def _get_gamedatadir(self):
  259. # Absolute path to the game data directory
  260. path = next(OPT.gamedir.glob("*_Data"), None)
  261. assert path, "Cannot determine game data dir"
  262. self.gamedatadir = path.name
  263. def _initialize_update(self):
  264. """
  265. Find out what kind of installation we need to update
  266. """
  267. self._get_gamedatadir()
  268. if gamedir("GenshinImpact.exe").is_file():
  269. self.rel_type = "os"
  270. elif gamedir("YuanShen.exe").is_file():
  271. if self.gamedatadir.joinpath("Plugins", "PCGameSDK.dll").is_file():
  272. self.rel_type = "bb"
  273. else:
  274. self.rel_type = "cn"
  275. if not isinstance(self.rel_type, str):
  276. abortlog("Failed to detect release type. " \
  277. + f"Game executable in '{OPT.gamedir}' could not be found.")
  278. infolog(f"Release type: {self.rel_type}")
  279. # Retrieve the installed game version
  280. if not OPT.ignore_conditions:
  281. fullname = gamedir(self.gamedatadir, "globalgamemanagers")
  282. assert fullname.is_file(), "Game install is incomplete!"
  283. contents = fullname.read_bytes()
  284. ver = re.findall(br"\0(\d+\.\d+\.\d+)_\d+_\d+\0", contents)
  285. assert len(ver) == 1, "Broken script or corrupted game installation"
  286. self.installed_ver = ver[0].decode("utf-8")
  287. infolog(f"Installed game version: {self.installed_ver} (anchor 1: globalgamemanagers)")
  288. else:
  289. # Change this if needed
  290. self.installed_ver = "5.5.0"
  291. # Compare game version with what's contained in "config.ini"
  292. self.check_config_ini()
  293. def check_config_ini(self):
  294. """
  295. Internal function. Picks the version as follows: min(config.ini, globalgamemanagers)
  296. """
  297. fullname = gamedir("config.ini")
  298. if not fullname.is_file():
  299. warnlog("config.ini not found")
  300. return
  301. contents = fullname.read_text()
  302. ver = re.findall(r"game_version=(\d+\.\d+\.\d+)", contents)
  303. if len(ver) != 1:
  304. warnlog("config.ini is incomplete or corrupt")
  305. return
  306. infolog(f"Installed game version: {ver[0]} (anchor 2: config.ini)")
  307. # config.ini is updated last. Use the older version
  308. ver_cfg = [int(v) for v in ver[0].split(".")]
  309. ver_ggm = [int(v) for v in self.installed_ver.split(".")]
  310. if cmp_versions(ver_cfg, ver_ggm) == 1:
  311. warnlog("Potential issue: config.ini documents a more recent version!")
  312. # keep `self.installed_ver` to continue the update if possible
  313. else:
  314. # equal version or lower
  315. self.installed_ver = ver[0]
  316. def get_voiceover_packs(self):
  317. """
  318. Returns a set of the installed packs: { "en-us", "ja-jp", "ko-kr", "zh-cn" }
  319. """
  320. # This path is also specified in 'getGameConfigs'
  321. fullname = gamedir(self.gamedatadir, "Persistent/audio_lang_14")
  322. packs = set()
  323. for line in fullname.open("r"):
  324. line = line.strip()
  325. if line == "":
  326. continue
  327. if not (line in VOICEOVERS_LUT):
  328. warnlog("Unknown voiceover pack in 'audio_lang_14': " + line)
  329. continue
  330. mediapath = gamedir(self.gamedatadir, "StreamingAssets/AudioAssets", line)
  331. num_files = len(list(mediapath.glob("*.*")))
  332. if num_files < 10:
  333. # These will be updated after the login screen
  334. infolog(f"Skipping voiceover pack '{line}': Pack was installed in-game.")
  335. continue
  336. packs.add(VOICEOVERS_LUT[line]["short"])
  337. debuglog("Found voiceover packs:", ", ".join(packs))
  338. return packs
  339. def update_voiceover_meta_file(self):
  340. """
  341. [Install only] Auto-detect installed language packs and update audio_lang_14
  342. """
  343. self._get_gamedatadir()
  344. languages = set()
  345. filename: pathlib.Path
  346. for filename in OPT.gamedir.glob("*"):
  347. groups = re.findall(r"^Audio_(.+)_pkg_version$", filename.name)
  348. if len(groups) != 1:
  349. continue
  350. longname = groups[0]
  351. if not (longname in VOICEOVERS_LUT):
  352. warnlog(f"Unknown voiceover pack '{filename.name}'")
  353. continue
  354. languages.add(longname)
  355. languages = list(languages)
  356. languages.sort()
  357. # Update the lang file
  358. langfile = gamedir(self.gamedatadir, "Persistent/audio_lang_14")
  359. lang_str = ", ".join(languages)
  360. if OPT.dry_run:
  361. infolog(f"[update lang file: {lang_str}]")
  362. return
  363. langfile.parent.mkdir(parents=True, exist_ok=True)
  364. with langfile.open("w", newline="\r\n") as fh:
  365. for lang in languages:
  366. fh.write(lang + "\n")
  367. infolog(f"Wrote the lang file to contain '{lang_str}'")
  368. def cleanup_temp(self):
  369. """
  370. Removes all temporary files
  371. """
  372. # DANGER
  373. if OPT.tempdir.resolve() in OPT.gamedir.resolve():
  374. abortlog("Temp is within the game directory.")
  375. if OPT.gamedir.resolve() in OPT.tempdir.resolve():
  376. abortlog("Temp is a parent of the game directory.")
  377. if OPT.tempdir.resolve() in SCRIPTDIR:
  378. abortlog("Temp is a parent of this script.")
  379. assert False
  380. if OPT.dry_run:
  381. info(f"[Delete temp dir '{OPT.tempdir}']")
  382. return
  383. shutil.rmtree(OPT.tempdir)
  384. def load_cached_api_file(self, fname, url, POST_data = None):
  385. """
  386. Cached file download. For JSON (API) files only!
  387. fname: file name without path prefix
  388. url: str or function ptr to retrieve the URL
  389. Returns: File handle
  390. """
  391. fullname = tempdir(fname)
  392. do_download = True
  393. if fullname.is_file():
  394. # keep cached for 24 hours
  395. do_download = time.time() - fullname.stat().st_mtime > (24 * 3600)
  396. if OPT.force_use_cache:
  397. do_download = False
  398. if do_download:
  399. # Check whether the file is still up-to-date
  400. if callable(url):
  401. url = url()
  402. if POST_data != None:
  403. req = request.Request(url, data=POST_data)
  404. resp = request.urlopen(req)
  405. with fullname.open("wb") as fh:
  406. fh.write(resp.read())
  407. else:
  408. request.urlretrieve(url, fullname)
  409. debuglog(f"Downloaded new file '{fname}'") #, src={url}")
  410. else:
  411. debuglog(f"Loaded existing file '{fname}'")
  412. return fullname
  413. def load_or_download_json(self, fname, url):
  414. path = self.load_cached_api_file(fname, url)
  415. with path.open("rb") as fh:
  416. js = json.load(fh)
  417. ret = js["retcode"]
  418. assert ret == 0, (f"Failed to retrieve '{fname}': " +
  419. f"server returned status code {ret} ({js['message']})")
  420. return js["data"]
  421. def retrieve_API_keys(self):
  422. """
  423. Retrieves passkeys for authentication to download URLs
  424. Depends on "initialize_*".
  425. """
  426. assert isinstance(self.rel_type, str), "Missing initialize"
  427. base_url: str = None
  428. if self.rel_type == "os":
  429. base_url = "https://sg-hyp-api.hoy" + "overse.com/hyp/hyp-connect/api"
  430. else:
  431. base_url = "https://hyp-api.mih" + "oyo.com/hyp/hyp-connect/api"
  432. warnlog("CN/BB is yet not tested!")
  433. game_ids: str = None
  434. launcher_id: str = None
  435. if self.rel_type == "os":
  436. # Up-to-date as of 2024-06-15 (4.7.0)
  437. game_ids = "gopR6Cufr3"
  438. launcher_id = "VYTpXlbWo8"
  439. elif self.rel_type == "cn":
  440. # From DGP-Studio/Snap.Hutao (GitHub), MIT
  441. launcher_id = "jGHBHlcOq1"
  442. game_ids = "1Z8W5NHUQb"
  443. elif self.rel_type == "bb":
  444. # From DGP-Studio/Snap.Hutao (GitHub), MIT
  445. launcher_id = "umfgRO5gh5"
  446. game_ids = "T2S0Gz4Dr2"
  447. else:
  448. assert False, "unhandled rel_type"
  449. tail = f"game_ids[]={game_ids}&launcher_id={launcher_id}"
  450. if not self.branches_json:
  451. # MANDATORY. JSON with package_id, password and tag(s)
  452. js = self.load_or_download_json("getGameBranches.json", f"{base_url}/getGameBranches?{tail}")
  453. # Array length corresponds to the amount of "game_ids" requested.
  454. self.branches_json = js["game_branches"][0][self.branch]
  455. assert self.branches_json is not None, \
  456. "Cannot find API keys for the selected branch. Maybe retry without pre-download?"
  457. ver = self.branches_json["tag"]
  458. infolog(f"Sophon provides game version {ver}")
  459. if False: # TODO
  460. # JSON with game paths for voiceover packs, logs, screenshots
  461. self.load_cached_api_file("getGameConfigs.json", f"{base_url}/getGameConfigs?{tail}")
  462. if False: # TODO
  463. # JSON with SDK files (BiliBili ?)
  464. channel = 1
  465. sub_channel = 0
  466. self.load_cached_api_file("getGameChannelSDKs.json",
  467. f"{base_url}/getGameChannelSDKs?channel={channel}&{tail}&sub_channel={sub_channel}")
  468. def make_getBuild_url(self, api_file):
  469. """
  470. Compose the URL for the main JSON file for chunk-based downloads (sophon)
  471. api_file: 'getPatchBuild' or 'getBuild'
  472. Returns: URL
  473. """
  474. if not self.branches_json:
  475. self.retrieve_API_keys()
  476. url: str = None
  477. if OPT.do_update:
  478. if self.rel_type == "os":
  479. url = "sg-downloader-api.ho" + "yoverse.com"
  480. elif self.rel_type == "cn":
  481. assert False, "TODO"
  482. else:
  483. if self.rel_type == "os":
  484. url = "sg-public-api.ho" + "yoverse.com"
  485. elif self.rel_type == "cn":
  486. url = "api-takumi.mih" + "oyo.com"
  487. assert not (url is None), f"Unhandled release type {self.rel_type}"
  488. url = (
  489. "https://" + url + "/downloader/sophon_chunk/api/" + api_file
  490. + "?branch=" + self.branches_json["branch"]
  491. + "&package_id=" + self.branches_json["package_id"]
  492. + "&password=" + self.branches_json["password"]
  493. )
  494. infolog("Created " + api_file + " JSON URL")
  495. return url
  496. def get_getBuild_json(self, is_new_file: bool):
  497. """
  498. Returns the main JSON for manifest and chunk/diff information
  499. is_new_file:
  500. True: For new files manifest
  501. False: For patch files manifest
  502. """
  503. api = "getBuild" if is_new_file else "getPatchBuild"
  504. path = self.load_cached_api_file(f"{api}.json", lambda : self.make_getBuild_url(api),
  505. # POST is required for patch
  506. None if is_new_file else []
  507. )
  508. contents = None
  509. with path.open("rb") as fh:
  510. contents = json.load(fh)
  511. debuglog(f"Loaded {api} JSON")
  512. return contents
  513. def _select_category(self, dlinfo: DownloadInfo, cat_name):
  514. """
  515. Retrieves the manifest to download the specified category
  516. Fills in the 'DownloadInfo' values
  517. """
  518. jd = dlinfo.getBuild_json["data"]
  519. infolog(dlinfo.name, f"Server provides game version {jd['tag']}")
  520. category = None
  521. fuzzy_str = ""
  522. # Precise search
  523. for jdm in jd["manifests"]:
  524. if jdm["matching_field"] == cat_name:
  525. category = jdm
  526. break
  527. if not category and not cat_name == "main":
  528. fuzzy_str = " (fuzzy match)"
  529. # Fuzzy match
  530. for jdm in jd["manifests"]:
  531. if cat_name in jdm["matching_field"]:
  532. if category:
  533. abortlog(f"Ambigous category '{cat_name}'")
  534. category = jdm
  535. cat_name = category["matching_field"]
  536. assert not (category is None), f"Cannot find the specified field '{cat_name}'"
  537. debuglog(dlinfo.name, f"Found category '{cat_name}'" + fuzzy_str)
  538. dlinfo.category_json = category
  539. # Download and decompress manifest protobuf
  540. fname_raw = category["manifest"]["id"]
  541. url = category["manifest_download"]["url_prefix"] + "/" + category["manifest"]["id"]
  542. zstd_path = self.load_cached_api_file(fname_raw + ".zstd", url)
  543. with zstd_path.open('br') as zfh:
  544. reader = zstandard.ZstdDecompressor().stream_reader(zfh)
  545. pb = None
  546. if dlinfo == self.di_diffs:
  547. pb = manifest_ldiff_pb2.DiffManifest()
  548. elif dlinfo == self.di_chunks:
  549. pb = manifest_pb2.Manifest()
  550. else:
  551. assert False, "unknown instance"
  552. pb.ParseFromString(reader.read())
  553. nfiles = len(pb.files)
  554. debuglog(dlinfo.name, f"Decompressed manifest protobuf ({nfiles} files)")
  555. if EXPORT_JSON_FILES:
  556. # For development purposes: write the manifest as JSON to a file
  557. # NOTE: Underscores may be converted to uppercase letters
  558. json_fname = tempdir(fname_raw + ".json")
  559. if not json_fname.is_file():
  560. with json_fname.open("w+") as jfh:
  561. json.dump(json.loads(MessageToJson(pb)), jfh)
  562. infolog(dlinfo.name, "Exported protobuf to JSON file")
  563. dlinfo.manifest = pb
  564. def load_manifest(self, cat_name):
  565. """
  566. Retrieve information about available patches/chunks for each game version
  567. cat_name: "game", "en-us", "zh-cn", "ja-jp", "ko-kr"
  568. """
  569. if not self.di_chunks.getBuild_json:
  570. self.di_chunks.getBuild_json = self.get_getBuild_json(True)
  571. if OPT.do_update:
  572. # Error early.
  573. if self.installed_ver == self.di_chunks.getBuild_json["data"]["tag"]:
  574. abortlog("There is no update available.")
  575. # The rest of the fucking owl
  576. self._select_category(self.di_chunks, cat_name)
  577. if OPT.do_update:
  578. # Do almost the same thing again
  579. if not self.di_diffs.getBuild_json:
  580. self.di_diffs.getBuild_json = self.get_getBuild_json(False)
  581. self._select_category(self.di_diffs, cat_name)
  582. def ldiff_manifest_required(self):
  583. assert isinstance(self.di_diffs.manifest, manifest_ldiff_pb2.DiffManifest), \
  584. "ldiff manifest is missing or invalid"
  585. def find_chunks_by_file_name(self, file_name):
  586. """
  587. Helper function. Searches a specific file name in the manifest
  588. Returns: FileInfo or None
  589. """
  590. assert isinstance(file_name, str)
  591. for v in self.di_chunks.manifest.files:
  592. if v.filename == file_name:
  593. return v
  594. warnlog(f"Cannot find chunks for file: {file_name}")
  595. return None
  596. def get_chunk_download_size(self, filter_by_new: bool) -> int:
  597. """
  598. Returns the sum of the chunk sizes
  599. """
  600. download_size_total: int = 0
  601. for v in self.di_chunks.manifest.files:
  602. if filter_by_new:
  603. if not (v.filename in self.new_files_to_download):
  604. continue
  605. for c in v.chunks:
  606. download_size_total += c.compressed_size
  607. return download_size_total
  608. def _download_file_resume(self, url: str, dstfile: pathlib.Path, dstsize: int):
  609. """
  610. Helper function.
  611. Downloads a file. Resumes interrupted downloads.
  612. """
  613. filesize = try_get_file_size(dstfile)
  614. if filesize == dstsize:
  615. # Already downloaded
  616. return
  617. if filesize > dstsize:
  618. # Corrupt. Delete.
  619. if OPT.dry_run:
  620. warnlog(f"[remove corrupted file '{dstfile.name}'")
  621. else:
  622. warnlog(f"Removing corrupted file: {dstfile.name}")
  623. dstfile.unlink() # delete
  624. filesize = 0
  625. req = request.Request(url)
  626. # Resume download if needed
  627. if filesize > 0:
  628. req.add_header("Range", f"bytes={filesize}-")
  629. resp = None
  630. try:
  631. resp = request.urlopen(req)
  632. #print(resp.status)
  633. #print(resp.headers)
  634. except urllib.error.HTTPError as e:
  635. # 416: Out of range. Our _tmp file is already complete.
  636. if e.code != 416:
  637. abortlog(f"Cannot download: {e}")
  638. resp = None
  639. if not resp:
  640. return
  641. # Write chunks of 10 MiB to the temporary file
  642. fh = dstfile.open("ab")
  643. while True:
  644. data = resp.read(10 * 1024 * 1024)
  645. if not data:
  646. break
  647. fh.write(data)
  648. #abortlog("testing: simulated download abort")
  649. def download_game_file(self, file_info: manifest_pb2.FileInfo):
  650. """
  651. Downloads the chunks and patches a file
  652. file_info: FileInfo, one of the manifest.files[] objects
  653. Returns `True` if the file is (now) present.
  654. """
  655. if file_info.flags == 64:
  656. # Created as soon a file is put inside
  657. infolog(f"Skipping directory entry: {file_info.filename}")
  658. return False
  659. assert (file_info.flags == 0), f"Unknown flags {file_info.flags} for '{file_info.filename}'"
  660. if OPT.TESTING_FILE and not (OPT.TESTING_FILE in file_info.filename):
  661. return True
  662. filename_safety_check(file_info.filename)
  663. filename = pathlib.Path(file_info.filename) # "UnityGame_Data/Subdirectory/file.txt"
  664. # Check whether the file already exists
  665. if try_get_file_size(gamedir(filename)) == file_info.size:
  666. #infolog(f"File '{filename.name}' already exists. ")
  667. return True
  668. CHUNK_URL_PREFIX = self.di_chunks.category_json["chunk_download"]["url_prefix"]
  669. # Inform the user
  670. size_mib = bytes_to_MiB(file_info.size)
  671. infolog(f"Downloading '{filename.name}', {size_mib} MiB, {len(file_info.chunks)} chunks")
  672. if OPT.disallow_download:
  673. warnlog(f"NOT downloading chunks for {filename.name}")
  674. return
  675. # Download to the temporary directory. Move after we're done.
  676. dstfile = tempdir(filename.name)
  677. bytes_written = 0
  678. while True: # run once
  679. if try_get_file_size(dstfile) == file_info.size:
  680. # File was already downloaded but not moved (e.g. out of space)
  681. break
  682. fh = dstfile.open("wb")
  683. # Download all chunks
  684. for chunk in file_info.chunks:
  685. cfname = tempdir(chunk.chunk_id) # compressed file path
  686. if chunk.offset != bytes_written:
  687. warnlog("\t Unexpected offset. Seek may fail.")
  688. # Download chunk if not already done
  689. self._download_file_resume(CHUNK_URL_PREFIX + "/" + chunk.chunk_id, cfname, chunk.compressed_size)
  690. # Write chunk to file
  691. with cfname.open("rb") as zfh:
  692. reader = zstandard.ZstdDecompressor().stream_reader(zfh)
  693. data = reader.read()
  694. fh.seek(chunk.offset)
  695. fh.write(data)
  696. bytes_written += len(data)
  697. debuglog(f"\t Progress: {(bytes_written * 100 / file_info.size):2.0f} % | "
  698. + f" {bytes_to_MiB(bytes_written)} / {size_mib} MiB", end="\r")
  699. print("") # Keep the last "100 %" line
  700. # Verify file integrity
  701. md5 = hashlib.md5(dstfile.read_bytes()).hexdigest()
  702. if file_info.md5 == md5:
  703. infolog("\t File is correct (md5 check)")
  704. else:
  705. dstfile.unlink() # delete
  706. abortlog(f"\t File is corrupt after download: {filename.name}. Please retry.")
  707. # Remove chunks after downloading
  708. for chunk in file_info.chunks:
  709. tempdir(chunk.chunk_id).unlink(True)
  710. # Move the completed files to the game directory
  711. if OPT.dry_run:
  712. infolog(f"[move new '{filename.name}' -> game dir]")
  713. return True
  714. gamefile = gamedir(filename).resolve()
  715. gamefile.parent.mkdir(parents=True, exist_ok=True)
  716. shutil.move(dstfile, gamefile)
  717. return True
  718. def update_config_ini_version(self):
  719. """
  720. Quick file sanity check + file update after install or update
  721. """
  722. if OPT.do_install + OPT.do_update != 1:
  723. abortlog("Invalid operation")
  724. confname = gamedir("config.ini")
  725. contents = confname.read_text()
  726. ver = re.findall(r"game_version=(\d+\.\d+\.\d+)", contents)
  727. if len(ver) != 1:
  728. warnlog("config.ini is incomplete or corrupt")
  729. return
  730. infolog("Checking game file integrity (quick) ...")
  731. # Do not abort in dry run
  732. error_fn = warnlog if OPT.dry_run else abortlog
  733. if OPT.do_install:
  734. for v in self.di_chunks.manifest.files:
  735. if v.flags == 64: # directory
  736. continue
  737. if try_get_file_size(gamedir(v.filename)) != v.size:
  738. error_fn(f"File missing or invalid size: {v.filename}")
  739. # Similar check after updating
  740. if OPT.do_update:
  741. self.ldiff_manifest_required()
  742. for v in self.di_diffs.manifest.files:
  743. if try_get_file_size(gamedir(v.filename)) != v.size:
  744. error_fn(f"File missing or invalid size: {v.filename}")
  745. # Check whether all old files are gone
  746. # Similar to "self.process_deletefiles"
  747. # Default to empty list in case there are no files to delete.
  748. deletelist: manifest_ldiff_pb2.PatchInfo = []
  749. for v in self.di_diffs.manifest.files_delete:
  750. if v.key == self.installed_ver:
  751. deletelist = v.info.list
  752. for v in deletelist:
  753. if gamedir(v.filename).is_file():
  754. error_fn(f"Old file still exists: {v.filename}")
  755. self.installed_ver = self.di_chunks.getBuild_json["data"]["tag"] # "MAJOR.MINOR.PATCH"
  756. contents = contents.replace(ver[0], self.installed_ver)
  757. if OPT.dry_run:
  758. infolog(f"[update config.ini to {self.installed_ver}]")
  759. return
  760. confname.write_text(contents)
  761. infolog(f"Updated config.ini to {self.installed_ver}")
  762. def get_ldiff_patchinfo(self, v) -> manifest_ldiff_pb2.PatchInfo:
  763. """
  764. Helper function. Find the patch file for our installed binary
  765. May return `None` if the file has not been changed.
  766. """
  767. pinfo: manifest_ldiff_pb2.PatchInfo = None
  768. for w in v.patches:
  769. if w.key == self.installed_ver:
  770. pinfo = w.info
  771. break
  772. return pinfo
  773. def _download_ldiff_file(self, ldiff_dir: pathlib.Path, v: manifest_ldiff_pb2.DiffFileInfo):
  774. """
  775. Helper function to download one diff file.
  776. Returns the patch file name on success, else None.
  777. """
  778. if len(v.patches) == 0:
  779. # These will be downloaded by chunks.
  780. #fn = pathlib.Path(v.filename).name
  781. #debuglog(f"File '{fn}' has no patches. Need to download by chunks.")
  782. self.new_files_to_download.add(v.filename)
  783. return None
  784. if OPT.TESTING_FILE:
  785. if not (OPT.TESTING_FILE in v.filename):
  786. return None
  787. else:
  788. print("ENTER TO DOWNLOAD: ", v.filename)
  789. input()
  790. filename_safety_check(v.filename)
  791. # Find the patch file for our installed binary
  792. pinfo = self.get_ldiff_patchinfo(v)
  793. if not pinfo:
  794. return None # The file was not modified in the new version
  795. # Check whether the file is ready for patching
  796. while True: # run once
  797. gamefile = gamedir(v.filename)
  798. gamefilesize = try_get_file_size(gamefile)
  799. md5 = None
  800. if gamefilesize == pinfo.original_size:
  801. # Ready for patching (do we want to compute the md5 hash here?)
  802. break
  803. if gamefilesize == v.size:
  804. # Maybe already up-to-date?
  805. md5 = hashlib.md5(gamefile.read_bytes()).hexdigest()
  806. if md5 == v.hash:
  807. debuglog(f"File '{gamefile.name}' is already up-to-date. Skipping.")
  808. return None
  809. if gamefilesize == -1:
  810. # For some reason, patch files may be given for new files (?)
  811. # How did they generate the patch?
  812. infolog(f"Cannot find file '{gamefile.name}'. Adding to chunk download queue.")
  813. self.new_files_to_download.add(v.filename)
  814. return None
  815. md5 = md5 if md5 else hashlib.md5(gamefile.read_bytes()).hexdigest()
  816. warnlog(f"md5 hash mismatch in '{gamefile.name}'. is={md5}, should={pinfo.original_hash} or {v.hash}")
  817. self.new_files_to_download.add(v.filename)
  818. # TODO. shall the file be removed?
  819. return None
  820. ldiffname = ldiff_dir.joinpath(pinfo.patch_id)
  821. if try_get_file_size(ldiffname) == pinfo.patch_size:
  822. # Already downloaded. Skip.
  823. # TODO: do a proper hash check
  824. debuglog(f"Diff '{ldiffname.name}' is already present. Skipping download.")
  825. return ldiffname.name
  826. tmp_file = pathlib.Path(f"{ldiffname}_tmp")
  827. size_mib = bytes_to_MiB(pinfo.patch_size)
  828. infolog(f"Downloading diff for '{v.filename}', {size_mib} MiB\n"
  829. f"\t -> {pinfo.patch_id}"
  830. )
  831. if OPT.disallow_download:
  832. warnlog(f"NOT downloading diff for {ldiffname.name}")
  833. return None
  834. DIFF_URL_PREFIX = self.di_diffs.category_json["diff_download"]["url_prefix"]
  835. self._download_file_resume(DIFF_URL_PREFIX + "/" + pinfo.patch_id, tmp_file, pinfo.patch_size)
  836. debuglog("Download done")
  837. # Verify patch file size (TODO: what's the purpose of the file name?)
  838. assert tmp_file.stat().st_size == pinfo.patch_size, "Corrupted patch download"
  839. # Move to original ldiff file name (without _tmp)
  840. # This does not need special dry-run handling (game files are not affected)
  841. shutil.move(tmp_file, ldiffname)
  842. return ldiffname.name
  843. def _apply_ldiff_file(self, ldiff_dir: pathlib.Path, v: manifest_ldiff_pb2.DiffFileInfo):
  844. """
  845. Helper function to apply one diff file.
  846. """
  847. assert (not OPT.predownload or OPT.TESTING_FILE), "Not allowed for pre-downloads."
  848. assert not (v.filename in self.new_files_to_download), "invalid script usage"
  849. if OPT.TESTING_FILE and not (OPT.TESTING_FILE in v.filename):
  850. return
  851. filename_safety_check(v.filename)
  852. # Find the patch file for our installed binary
  853. pinfo = self.get_ldiff_patchinfo(v)
  854. if not pinfo:
  855. return
  856. gamefile = gamedir(v.filename)
  857. # Patched file goes into the temporary directory (at first)
  858. dstfile = tempdir(pathlib.Path(v.filename).name)
  859. dstfile.unlink(True) # remove any existing duplicate temporary file
  860. ldiffname = ldiff_dir.joinpath(pinfo.patch_id)
  861. if not ldiffname.is_file():
  862. if OPT.disallow_download:
  863. return
  864. abortlog(f"Diff file {ldiffname.name} is missing. Please redownload.")
  865. # Apply the patch file
  866. done = hpatchz_patch_file(gamefile, dstfile, ldiffname, pinfo.patch_offset, pinfo.patch_length)
  867. if not done:
  868. # retry with longer timeout
  869. done = hpatchz_patch_file(gamefile, dstfile, ldiffname, pinfo.patch_offset, pinfo.patch_length, 300)
  870. # Verify patched file integrity (NOTE: hpatchz might already have checked it)
  871. assert dstfile.stat().st_size == v.size
  872. md5 = hashlib.md5(dstfile.read_bytes()).hexdigest()
  873. if md5 != v.hash:
  874. warnlog(f"Checksum failed on file {v.filename}. Corrupt?")
  875. # Retry by downloading from scratch
  876. self.new_files_to_download.add(v.filename)
  877. else:
  878. infolog(f"Patched file {v.filename}")
  879. # Replace the game install file
  880. if OPT.dry_run:
  881. infolog(f"[move patched '{dstfile.name}' -> game dir]")
  882. return
  883. shutil.move(dstfile, gamefile)
  884. def apply_or_prepare_ldiff_files(self):
  885. """
  886. Downloads the ldiff files and patches the destination file if not predownload.
  887. Requires self.load_manifest(CATEGORY)
  888. """
  889. self.ldiff_manifest_required()
  890. assert len(self.new_files_to_download) == 0, "List must be empty!"
  891. ldiff_dir = gamedir("ldiff")
  892. ldiff_dir.mkdir(exist_ok=True)
  893. # Sum up the entire download size
  894. download_sizes_checked = set() # values: patchname
  895. download_size_total = 0
  896. for v in self.di_diffs.manifest.files:
  897. pinfo = self.get_ldiff_patchinfo(v)
  898. if not pinfo:
  899. continue
  900. if pinfo.patch_id in download_sizes_checked:
  901. continue
  902. download_sizes_checked.add(pinfo.patch_id)
  903. download_size_total += pinfo.patch_length
  904. infolog(f"Downloading ldiff files (up to {bytes_to_MiB(download_size_total)} MiB) ...")
  905. del download_sizes_checked
  906. del download_size_total
  907. # Not accurate when there are too many new files (chunks)
  908. files_total = len(self.di_diffs.manifest.files)
  909. files_done = 0
  910. what_txt = " and patched" if OPT.predownload else ""
  911. # Loop through the file list and download what's missing
  912. for v in self.di_diffs.manifest.files:
  913. # TODO: Download one file an apply the patches to all files that make use of it
  914. # Motivation: less space consumption by temporary files
  915. downloaded = self._download_ldiff_file(ldiff_dir, v)
  916. if downloaded:
  917. self.ldiff_files_to_remove.add(downloaded)
  918. if not OPT.predownload:
  919. # Normal case: update the file
  920. self._apply_ldiff_file(ldiff_dir, v)
  921. elif OPT.TESTING_FILE and (OPT.TESTING_FILE in v.filename):
  922. # Allow patching individual files beforehand
  923. warnlog(f"ENTER TO APPLY PATCH (will create backup file): ", OPT.TESTING_FILE)
  924. input()
  925. gamefile = gamedir(v.filename)
  926. shutil.copy2(gamefile, f"{gamefile}.bak")
  927. self._apply_ldiff_file(ldiff_dir, v)
  928. files_done += 1
  929. relname = pathlib.Path(v.filename).name
  930. infolog(f"Progress: {files_done} / {files_total} files | Downloaded: {relname}", end="\r")
  931. if files_done % 100 == 0:
  932. print("")
  933. infolog("\nFiles downloaded" + what_txt + ".") # keep the last "100 %" line
  934. # Note: the downloaded ldiff files are removed by `self.remove_ldiff_files`
  935. def process_deletefiles(self):
  936. """
  937. [Update only] Remove old files
  938. """
  939. self.ldiff_manifest_required()
  940. assert not OPT.predownload, "Not allowed for pre-downloads."
  941. # Default to empty list in case there are no files to delete.
  942. deletelist: manifest_ldiff_pb2.PatchInfo = []
  943. for v in self.di_diffs.manifest.files_delete:
  944. if v.key == self.installed_ver:
  945. deletelist = v.info.list
  946. infolog(f"Deleting {len(deletelist)} old files ...")
  947. for v in deletelist:
  948. filename_safety_check(v.filename)
  949. gamefile = gamedir(v.filename)
  950. if not gamefile.is_file():
  951. continue
  952. # Remove the file
  953. if OPT.dry_run:
  954. infolog(f"[delete old file {v.filename}]")
  955. continue
  956. infolog(f"Deleted old file: {v.filename}")
  957. gamefile.unlink() # remove
  958. def diff_download_new_files(self):
  959. """
  960. [Update/repair only] Downloads files that were added in the new version.
  961. """
  962. if OPT.predownload:
  963. infolog("New files download is DISABLED for predownloads!")
  964. self.new_files_to_download.clear()
  965. return
  966. download_size_total = self.get_chunk_download_size(True)
  967. infolog(f"Downloading newly added files (up to {bytes_to_MiB(download_size_total)} MiB) ...")
  968. del download_size_total
  969. # Not accurate when there are too many new files (chunks)
  970. files_total = len(self.new_files_to_download)
  971. files_done = 0
  972. for v in self.di_chunks.manifest.files:
  973. if not (v.filename in self.new_files_to_download):
  974. continue
  975. self.download_game_file(v)
  976. files_done += 1
  977. relname = pathlib.Path(v.filename).name
  978. infolog(f"Progress: {files_done} / {files_total} files | Downloaded: {relname}",
  979. end="\r")
  980. if files_done % 100 == 0:
  981. print("")
  982. infolog("Download complete.")
  983. self.new_files_to_download.clear()
  984. def remove_ldiff_files(self):
  985. """
  986. [Update only] Removes all downloaded ldiff files
  987. """
  988. self.ldiff_manifest_required()
  989. assert not OPT.predownload, "Not allowed for pre-downloads."
  990. ldiff_dir = gamedir("ldiff")
  991. if not ldiff_dir.is_dir():
  992. warnlog(f"Directory {ldiff_dir} not found. Cannot cleanup.")
  993. return
  994. count = 0
  995. # TODO: This does not remove all files downloaded by the official launcher
  996. # because it also downloads new files. How can those be applied?
  997. for v in self.ldiff_files_to_remove:
  998. filename : pathlib.Path = ldiff_dir.joinpath(v)
  999. if not filename.is_file():
  1000. continue
  1001. count += 1
  1002. if OPT.dry_run:
  1003. infolog(f"[remove now unused ldiff '{v}']")
  1004. continue
  1005. filename.unlink() # delete
  1006. infolog(f"Cleaned up {count} now unused ldiff files.")
  1007. def repair_by_category(self, cat_name: str):
  1008. """
  1009. Use sophon chunks to restore missing or incorrect files
  1010. `load_manifest` must be used to select the correct category to repair
  1011. """
  1012. assert not OPT.predownload, "Not allowed for pre-downloads."
  1013. # Here we can either use the manifest or pkg_version
  1014. self.load_manifest(cat_name)
  1015. if self.installed_ver != self.di_chunks.getBuild_json["data"]["tag"]:
  1016. abortlog("The installed version is outdated. Run an update first.")
  1017. self.new_files_to_download.clear()
  1018. reliable_checking = (OPT.repair_mode == "reliable")
  1019. infolog(f"Repair started. Mode: {reliable_checking}")
  1020. files_checked = 0
  1021. files_total = len(self.di_chunks.manifest.files)
  1022. for v in self.di_chunks.manifest.files:
  1023. files_checked += 1
  1024. debuglog(f"\t Progress: {(files_checked * 100 / files_total):2.0f} % | "
  1025. + f" {files_checked} / {files_total} files checked", end="\r")
  1026. reason = None
  1027. gamefile = gamedir(v.filename)
  1028. gamefilesize = try_get_file_size(gamefile)
  1029. if gamefilesize != v.size:
  1030. reason = f"size mismatch. is={gamefilesize}, should={v.size}"
  1031. elif reliable_checking:
  1032. md5 = hashlib.md5(gamefile.read_bytes()).hexdigest()
  1033. if md5 != v.md5:
  1034. reason = f"md5 mismatch. is={md5}, should={v.md5}"
  1035. if not reason:
  1036. continue # file is OK
  1037. print("")
  1038. infolog(f"Need to repair file '{v.filename}': " + reason)
  1039. self.new_files_to_download.add(v.filename)
  1040. print("") # Keep the last "100 %" line
  1041. self.diff_download_new_files()