From 82adbe4fc7bbe2a7f57bd99aebb27f5f56a80384 Mon Sep 17 00:00:00 2001 From: a Date: Fri, 10 Nov 2023 16:34:12 +0200 Subject: [PATCH] greatly enhanced the functionality of the generate_emu_config script + build script + updated .gitignore --- .gitignore | 12 + .../parse_controller_vdf.py | 13 +- .../external_components/ach_watcher_gen.py | 173 ++++ scripts/external_components/app_details.py | 230 +++++ scripts/external_components/app_images.py | 94 ++ scripts/external_components/cdx_gen.py | 157 +++ scripts/external_components/safe_name.py | 22 + scripts/generate_emu_config.py | 918 ++++++++++++++---- scripts/icon/Froyoshark-Enkel-Steam.ico | Bin 0 -> 181772 bytes scripts/rebuild.bat | 37 + scripts/recreate_venv.bat | 15 + scripts/requirements.txt | 2 + .../achievements_gen.py | 59 +- scripts/steam_default_icon_locked.jpg | Bin 0 -> 4002 bytes scripts/steam_default_icon_unlocked.jpg | Bin 0 -> 4171 bytes 15 files changed, 1507 insertions(+), 225 deletions(-) create mode 100644 scripts/external_components/ach_watcher_gen.py create mode 100644 scripts/external_components/app_details.py create mode 100644 scripts/external_components/app_images.py create mode 100644 scripts/external_components/cdx_gen.py create mode 100644 scripts/external_components/safe_name.py create mode 100644 scripts/icon/Froyoshark-Enkel-Steam.ico create mode 100644 scripts/rebuild.bat create mode 100644 scripts/recreate_venv.bat create mode 100644 scripts/requirements.txt create mode 100644 scripts/steam_default_icon_locked.jpg create mode 100644 scripts/steam_default_icon_unlocked.jpg diff --git a/.gitignore b/.gitignore index 3018cf70..5ac977c1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,15 @@ base.lib steamclient.exp steamclient.lib out/* + +scripts/.py/ +scripts/.venv/ +scripts/.env/ +scripts/.vscode/ +scripts/backup/ +scripts/bin/ +scripts/build_tmp/ +scripts/login_temp/ +scripts/**/__pycache__/ +scripts/generate_emu_config.spec +scripts/my_login.txt diff --git a/scripts/controller_config_generator/parse_controller_vdf.py b/scripts/controller_config_generator/parse_controller_vdf.py index 4c9495e2..7e9d17e4 100644 --- a/scripts/controller_config_generator/parse_controller_vdf.py +++ b/scripts/controller_config_generator/parse_controller_vdf.py @@ -160,13 +160,14 @@ def generate_controller_config(controller_vdf, config_dir): #print(all_bindings) - if not os.path.exists(config_dir): - os.makedirs(config_dir) + if all_bindings: + if not os.path.exists(config_dir): + os.makedirs(config_dir) - for k in all_bindings: - with open(os.path.join(config_dir, k + '.txt'), 'w') as f: - for b in all_bindings[k]: - f.write(b + "=" + ','.join(all_bindings[k][b]) + "\n") + for k in all_bindings: + with open(os.path.join(config_dir, k + '.txt'), 'w') as f: + for b in all_bindings[k]: + f.write(b + "=" + ','.join(all_bindings[k][b]) + "\n") if __name__ == '__main__': diff --git a/scripts/external_components/ach_watcher_gen.py b/scripts/external_components/ach_watcher_gen.py new file mode 100644 index 00000000..7cea6adf --- /dev/null +++ b/scripts/external_components/ach_watcher_gen.py @@ -0,0 +1,173 @@ +import copy +import os +import time +import json + +def __ClosestDictKey(targetKey : str, srcDict : dict[str, object] | set[str]) -> str | None: + for k in srcDict: + if k.lower() == f"{targetKey}".lower(): + return k + + return None + +def __generate_ach_watcher_schema(lang: str, app_id: int, achs: list[dict]) -> list[dict]: + out_achs_list = [] + for idx in range(len(achs)): + ach = copy.deepcopy(achs[idx]) + out_ach_data = {} + + # adjust the displayName + displayName = "" + ach_displayName = ach.get("displayName", None) + if ach_displayName: + if type(ach_displayName) == dict: # this is a dictionary + displayName : str = ach_displayName.get(lang, "") + if not displayName and ach_displayName: # has some keys but language not found + #print(f'[?] Missing language "{lang}" in "displayName" of achievement {ach["name"]}') + nearestLang = __ClosestDictKey(lang, ach_displayName) + if nearestLang: + #print(f'[?] Best matching language "{nearestLang}"') + displayName = ach_displayName[nearestLang] + else: + print(f'[?] Missing language "{lang}", using displayName from the first language for achievement {ach["name"]}') + displayName : str = list(ach_displayName.values())[0] + else: # single string (or anything else) + displayName = ach_displayName + + del ach["displayName"] + else: + print(f'[?] Missing "displayName" in achievement {ach["name"]}') + + out_ach_data["displayName"] = displayName + + desc = "" + ach_desc = ach.get("description", None) + if ach_desc: + if type(ach_desc) == dict: # this is a dictionary + desc : str = ach_desc.get(lang, "") + if not desc and ach_desc: # has some keys but language not found + #print(f'[?] Missing language "{lang}" in "description" of achievement {ach["name"]}') + nearestLang = __ClosestDictKey(lang, ach_desc) + if nearestLang: + #print(f'[?] Best matching language "{nearestLang}"') + desc = ach_desc[nearestLang] + else: + print(f'[?] Missing language "{lang}", using description from the first language for achievement {ach["name"]}') + desc : str = list(ach_desc.values())[0] + else: # single string (or anything else) + desc = ach_desc + + del ach["description"] + else: + print(f'[?] Missing "description" in achievement {ach["name"]}') + + # adjust the description + out_ach_data["description"] = desc + + # copy the rest of the data + out_ach_data.update(ach) + + # add links to icon, icongray, and icon_gray + base_icon_url = r'https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps' + icon_hash = out_ach_data.get("icon", None) + if icon_hash: + out_ach_data["icon"] = f'{base_icon_url}/{app_id}/{icon_hash}' + else: + out_ach_data["icon"] = "" + + icongray_hash = out_ach_data.get("icongray", None) + if icongray_hash: + out_ach_data["icongray"] = f'{base_icon_url}/{app_id}/{icongray_hash}' + else: + out_ach_data["icongray"] = "" + + icon_gray_hash = out_ach_data.get("icon_gray", None) + if icon_gray_hash: + del out_ach_data["icon_gray"] # use the old key + out_ach_data["icongray"] = f'{base_icon_url}/{app_id}/{icon_gray_hash}' + + if "hidden" in out_ach_data: + try: + out_ach_data["hidden"] = int(out_ach_data["hidden"]) + except Exception as e: + pass + else: + out_ach_data["hidden"] = 0 + + out_achs_list.append(out_ach_data) + + return out_achs_list + +def generate_all_ach_watcher_schemas( + base_out_dir : str, + appid: int, + app_name : str, + app_exe : str, + achs: list[dict], + small_icon_hash : str) -> None: + + ach_watcher_out_dir = os.path.join(base_out_dir, "Achievement Watcher", "steam_cache", "schema") + print(f"generating schemas for Achievement Watcher in: {ach_watcher_out_dir}") + + if app_exe: + print(f"detected app exe: '{app_exe}'") + else: + print(f"[X] couldn't detect app exe") + + # if not achs: + # print("[X] No achievements were found for Achievement Watcher") + # return + + small_icon_url = '' + if small_icon_hash: + small_icon_url = f"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{appid}/{small_icon_hash}.jpg" + images_base_url = r'https://cdn.cloudflare.steamstatic.com/steam/apps' + ach_watcher_base_schema = { + "appid": appid, + "name": app_name, + "binary": app_exe, + "achievement": { + "total": len(achs), + }, + "img": { + "header": f"{images_base_url}/{appid}/header.jpg", + "background": f"{images_base_url}/{appid}/page_bg_generated_v6b.jpg", + "portrait": f"{images_base_url}/{appid}/library_600x900.jpg", + "hero": f"{images_base_url}/{appid}/library_hero.jpg", + "icon": small_icon_url, + }, + "apiVersion": 1, + } + + langs : set[str] = set() + for ach in achs: + displayNameLangs = ach.get("displayName", None) + if displayNameLangs and type(displayNameLangs) == dict: + langs.update(list(displayNameLangs.keys())) + + descriptionLangs = ach.get("description", None) + if descriptionLangs and type(descriptionLangs) == dict: + langs.update(list(descriptionLangs.keys())) + + if "token" in langs: + langs.remove("token") + + tokenKey = __ClosestDictKey("token", langs) + if tokenKey: + langs.remove(tokenKey) + + if not langs: + print("[X] Couldn't detect supported languages, assuming English is the only supported language for Achievement Watcher") + langs = ["english"] + + for lang in langs: + out_schema_folder = os.path.join(ach_watcher_out_dir, lang) + if not os.path.exists(out_schema_folder): + os.makedirs(out_schema_folder) + time.sleep(0.050) + + out_schema = copy.copy(ach_watcher_base_schema) + out_schema["achievement"]["list"] = __generate_ach_watcher_schema(lang, appid, achs) + out_schema_file = os.path.join(out_schema_folder, f'{appid}.db') + with open(out_schema_file, "wt", encoding='utf-8') as f: + json.dump(out_schema, f, ensure_ascii=False, indent=2) diff --git a/scripts/external_components/app_details.py b/scripts/external_components/app_details.py new file mode 100644 index 00000000..3c497fcb --- /dev/null +++ b/scripts/external_components/app_details.py @@ -0,0 +1,230 @@ +import os +import json +import queue +import threading +import time +import requests +import urllib.parse +from external_components import ( + safe_name +) + +def __downloader_thread(q : queue.Queue[tuple[str, str]]): + while True: + url, path = q.get() + if not url: + q.task_done() + return + + # try 3 times + for download_trial in range(3): + try: + r = requests.get(url) + if r.status_code == requests.codes.ok: # if download was successfull + with open(path, "wb") as f: + f.write(r.content) + + break + except Exception: + pass + + time.sleep(0.1) + + q.task_done() + +def __remove_url_query(url : str) -> str: + url_parts = urllib.parse.urlsplit(url) + url_parts_list = list(url_parts) + url_parts_list[3] = '' # remove query + return str(urllib.parse.urlunsplit(url_parts_list)) + +def __download_screenshots( + base_out_dir : str, + appid : int, + app_details : dict, + download_screenshots : bool, + download_thumbnails : bool): + if not download_screenshots and not download_thumbnails: + return + + screenshots : list[dict[str, object]] = app_details.get(f'{appid}', {}).get('data', {}).get('screenshots', []) + if not screenshots: + print(f'[?] no screenshots or thumbnails are available') + return + + screenshots_out_dir = os.path.join(base_out_dir, "screenshots") + if download_screenshots: + print(f"downloading screenshots in: {screenshots_out_dir}") + if not os.path.exists(screenshots_out_dir): + os.makedirs(screenshots_out_dir) + time.sleep(0.025) + + thumbnails_out_dir = os.path.join(screenshots_out_dir, "thumbnails") + if download_thumbnails: + print(f"downloading screenshots thumbnails in: {thumbnails_out_dir}") + if not os.path.exists(thumbnails_out_dir): + os.makedirs(thumbnails_out_dir) + time.sleep(0.025) + + q : queue.Queue[tuple[str, str]] = queue.Queue() + + max_threads = 20 + for i in range(max_threads): + threading.Thread(target=__downloader_thread, args=(q,), daemon=True).start() + + for scrn in screenshots: + if download_screenshots: + full_image_url = scrn.get('path_full', None) + if full_image_url: + full_image_url_sanitized = __remove_url_query(full_image_url) + image_hash_name = f'{full_image_url_sanitized.rsplit("/", 1)[-1]}'.rstrip() + if image_hash_name: + q.put((full_image_url_sanitized, os.path.join(screenshots_out_dir, image_hash_name))) + else: + print(f'[X] cannot download screenshot from url: "{full_image_url}", failed to get image name') + + if download_thumbnails: + thumbnail_url = scrn.get('path_thumbnail', None) + if thumbnail_url: + thumbnail_url_sanitized = __remove_url_query(thumbnail_url) + image_hash_name = f'{thumbnail_url_sanitized.rsplit("/", 1)[-1]}'.rstrip() + if image_hash_name: + q.put((thumbnail_url_sanitized, os.path.join(thumbnails_out_dir, image_hash_name))) + else: + print(f'[X] cannot download screenshot thumbnail from url: "{thumbnail_url}", failed to get image name') + + q.join() + + for i in range(max_threads): + q.put((None, None)) + + q.join() + + print(f"finished downloading app screenshots") + +PREFERED_VIDS = [ + 'trailer', 'gameplay', 'announcement' +] + +def __download_videos(base_out_dir : str, appid : int, app_details : dict): + videos : list[dict[str, object]] = app_details.get(f'{appid}', {}).get('data', {}).get('movies', []) + if not videos: + print(f'[?] no videos were found') + return + + videos_out_dir = os.path.join(base_out_dir, "videos") + print(f"downloading app videos in: {videos_out_dir}") + + first_vid : tuple[str, str] = None + prefered_vid : tuple[str, str] = None + for vid in videos: + vid_name = f"{vid.get('name', '')}" + webm_url = vid.get('webm', {}).get("480", None) + mp4_url = vid.get('mp4', {}).get("480", None) + + ext : str = None + prefered_url : str = None + if mp4_url: + prefered_url = mp4_url + ext = 'mp4' + elif webm_url: + prefered_url = webm_url + ext = 'webm' + else: # no url is found + print(f'[X] no url is found for video "{vid_name}"') + continue + + vid_url_sanitized = __remove_url_query(prefered_url) + vid_name_in_url = f'{vid_url_sanitized.rsplit("/", 1)[-1]}'.rstrip() + vid_name = safe_name.create_safe_name(vid_name) + if vid_name: + vid_name = f'{vid_name}.{ext}' + else: + vid_name = vid_name_in_url + + if vid_name: + if not first_vid: + first_vid = (vid_url_sanitized, vid_name) + + if any(vid_name.lower().find(candidate) > -1 for candidate in PREFERED_VIDS): + prefered_vid = (vid_url_sanitized, vid_name) + + if prefered_vid: + break + else: + print(f'[X] cannot download video from url: "{prefered_url}", failed to get vido name') + + if not first_vid and not prefered_vid: + print(f'[X] no video url could be found') + return + elif not prefered_vid: + prefered_vid = first_vid + + if not os.path.exists(videos_out_dir): + os.makedirs(videos_out_dir) + time.sleep(0.05) + + q : queue.Queue[tuple[str, str]] = queue.Queue() + + max_threads = 1 + for i in range(max_threads): + threading.Thread(target=__downloader_thread, args=(q,), daemon=True).start() + + # TODO download all videos + print(f'donwloading video: "{prefered_vid[1]}"') + q.put((prefered_vid[0], os.path.join(videos_out_dir, prefered_vid[1]))) + q.join() + + for i in range(max_threads): + q.put((None, None)) + + q.join() + + print(f"finished downloading app videos") + + +def download_app_details( + base_out_dir : str, + info_out_dir : str, + appid : int, + download_screenshots : bool, + download_thumbnails : bool, + download_vids : bool): + + details_out_file = os.path.join(info_out_dir, "app_details.json") + print(f"downloading app details in: {details_out_file}") + + app_details : dict = None + last_exception : Exception | str = None + # try 3 times + for download_trial in range(3): + try: + r = requests.get(f'http://store.steampowered.com/api/appdetails?appids={appid}&format=json') + if r.status_code == requests.codes.ok: # if download was successfull + result : dict = r.json() + json_ok = result.get(f'{appid}', {}).get('success', False) + if json_ok: + app_details = result + break + else: + last_exception = "JSON success was False" + except Exception as e: + last_exception = e + + time.sleep(0.1) + + if not app_details: + err = "[X] failed to download app details" + if last_exception: + err += f', last error: "{last_exception}"' + + print(err) + return + + with open(details_out_file, "wt", encoding='utf-8') as f: + json.dump(app_details, f, ensure_ascii=False, indent=2) + + __download_screenshots(base_out_dir, appid, app_details, download_screenshots, download_thumbnails) + + if download_vids: + __download_videos(base_out_dir, appid, app_details) diff --git a/scripts/external_components/app_images.py b/scripts/external_components/app_images.py new file mode 100644 index 00000000..63139f16 --- /dev/null +++ b/scripts/external_components/app_images.py @@ -0,0 +1,94 @@ +import os +import threading +import time +import requests + + +def download_app_images( + base_out_dir : str, + appid : int, + clienticon : str, + icon : str, + logo : str, + logo_small : str): + + icons_out_dir = os.path.join(base_out_dir, "images") + print(f"downloading common app images in: {icons_out_dir}") + + def downloader_thread(image_name : str, image_url : str): + # try 3 times + for download_trial in range(3): + try: + r = requests.get(image_url) + if r.status_code == requests.codes.ok: # if download was successfull + with open(os.path.join(icons_out_dir, image_name), "wb") as f: + f.write(r.content) + + break + except Exception as ex: + pass + + time.sleep(0.1) + + app_images_names = [ + r'capsule_184x69.jpg', + r'capsule_231x87.jpg', + r'capsule_231x87_alt_assets_0.jpg', + r'capsule_467x181.jpg', + r'capsule_616x353.jpg', + r'capsule_616x353_alt_assets_0.jpg', + r'library_600x900.jpg', + r'library_600x900_2x.jpg', + r'library_hero.jpg', + r'broadcast_left_panel.jpg', + r'broadcast_right_panel.jpg', + r'page.bg.jpg', + r'page_bg_raw.jpg', + r'page_bg_generated.jpg', + r'page_bg_generated_v6b.jpg', + r'header.jpg', + r'header_alt_assets_0.jpg', + r'hero_capsule.jpg', + r'logo.png', + ] + + if not os.path.exists(icons_out_dir): + os.makedirs(icons_out_dir) + time.sleep(0.050) + + threads_list : list[threading.Thread] = [] + for image_name in app_images_names: + image_url = f'https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{image_name}' + t = threading.Thread(target=downloader_thread, args=(image_name, image_url), daemon=True) + threads_list.append(t) + t.start() + + community_images_url = f'https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{appid}' + if clienticon: + image_url = f'{community_images_url}/{clienticon}.ico' + t = threading.Thread(target=downloader_thread, args=('clienticon.ico', image_url), daemon=True) + threads_list.append(t) + t.start() + + if icon: + image_url = f'{community_images_url}/{icon}.jpg' + t = threading.Thread(target=downloader_thread, args=('icon.jpg', image_url), daemon=True) + threads_list.append(t) + t.start() + + if logo: + image_url = f'{community_images_url}/{logo}.jpg' + t = threading.Thread(target=downloader_thread, args=('logo.jpg', image_url), daemon=True) + threads_list.append(t) + t.start() + + if logo_small: + image_url = f'{community_images_url}/{logo_small}.jpg' + t = threading.Thread(target=downloader_thread, args=('logo_small.jpg', image_url), daemon=True) + threads_list.append(t) + t.start() + + for t in threads_list: + t.join() + + print(f"finished downloading common app images") \ No newline at end of file diff --git a/scripts/external_components/cdx_gen.py b/scripts/external_components/cdx_gen.py new file mode 100644 index 00000000..e3a06ae5 --- /dev/null +++ b/scripts/external_components/cdx_gen.py @@ -0,0 +1,157 @@ +import os + +__cdx_ini = ''' +### мллллл м +### Алллл плл лВ ппплллллллм пппппллВллм мВлллп +### Блллп Бллп ппллллА пллл Блллп +### Вллл п ллВ плллБ АллВллл +### Вллл млллллм ллл пллл мллллллм Бллллл +### лллА Аллллп плВ ллл лллВллВ Алл лллВллл +### Бллл ллллА лл ллл Алллллллллллп лллБ Бллл +### Алллм мллпВллм млл Влл лллБлллА млллА Алллм +### плллллп плллВп ллп АлллА плллллллВлп пВллм +### мллллллБ +### пппллВмммммлВлллВпп +### +### +### Game data is stored at %SystemDrive%\\Users\\Public\\Documents\\Steam\\CODEX\\{cdx_id} +### + +[Settings] +### +### Game identifier (http://store.steampowered.com/app/{cdx_id}) +### +AppId={cdx_id} +### +### Steam Account ID, set it to 0 to get a random Account ID +### +#AccountId=0 +### +### Name of the current player +### +UserName=Player2 +### +### Language that will be used in the game +### +Language=english +### +### Enable lobby mode +### +LobbyEnabled=1 +### +### Lobby port to listen on +### +#LobbyPort=31183 +### +### Enable/Disable Steam overlay +### +Overlays=1 +### +### Set Steam connection to offline mode +### +Offline=0 +### + +[Interfaces] +### +### Steam Client API interface versions +### +SteamAppList=STEAMAPPLIST_INTERFACE_VERSION001 +SteamApps=STEAMAPPS_INTERFACE_VERSION008 +SteamClient=SteamClient017 +SteamController=SteamController008 +SteamFriends=SteamFriends017 +SteamGameServer=SteamGameServer013 +SteamGameServerStats=SteamGameServerStats001 +SteamHTMLSurface=STEAMHTMLSURFACE_INTERFACE_VERSION_005 +SteamHTTP=STEAMHTTP_INTERFACE_VERSION003 +SteamInput=SteamInput002 +SteamInventory=STEAMINVENTORY_INTERFACE_V003 +SteamMatchGameSearch=SteamMatchGameSearch001 +SteamMatchMaking=SteamMatchMaking009 +SteamMatchMakingServers=SteamMatchMakingServers002 +SteamMusic=STEAMMUSIC_INTERFACE_VERSION001 +SteamMusicRemote=STEAMMUSICREMOTE_INTERFACE_VERSION001 +SteamNetworking=SteamNetworking006 +SteamNetworkingSockets=SteamNetworkingSockets008 +SteamNetworkingUtils=SteamNetworkingUtils003 +SteamParentalSettings=STEAMPARENTALSETTINGS_INTERFACE_VERSION001 +SteamParties=SteamParties002 +SteamRemotePlay=STEAMREMOTEPLAY_INTERFACE_VERSION001 +SteamRemoteStorage=STEAMREMOTESTORAGE_INTERFACE_VERSION014 +SteamScreenshots=STEAMSCREENSHOTS_INTERFACE_VERSION003 +SteamTV=STEAMTV_INTERFACE_V001 +SteamUGC=STEAMUGC_INTERFACE_VERSION015 +SteamUser=SteamUser021 +SteamUserStats=STEAMUSERSTATS_INTERFACE_VERSION012 +SteamUtils=SteamUtils010 +SteamVideo=STEAMVIDEO_INTERFACE_V002 +### + +[DLC] +### +### Automatically unlock all DLCs +### +DLCUnlockall=0 +### +### Identifiers for DLCs +### +#ID=Name +{cdx_dlc_list} +### + +[AchievementIcons] +### +### Bitmap Icons for Achievements +### +#halloween_8 Achieved=steam_settings\\img\\halloween_8.jpg +#halloween_8 Unachieved=steam_settings\\img\\unachieved\\halloween_8.jpg +{cdx_ach_list} +### + +[Crack] +00ec7837693245e3=b7d5bc716512b5d6 + +''' + + +def generate_cdx_ini( + base_out_dir : str, + appid: int, + dlc: list[tuple[int, str]], + achs: list[dict]) -> None: + + cdx_ini_path = os.path.join(base_out_dir, "steam_emu.ini") + print(f"generating steam_emu.ini for CODEX emulator in: {cdx_ini_path}") + + dlc_list = [f"{d[0]}={d[1]}" for d in dlc] + achs_list = [] + for ach in achs: + icon = ach.get("icon", None) + if icon: + icon = f"steam_settings\\img\\{icon}" + else: + icon = 'steam_settings\\img\\steam_default_icon_unlocked.jpg' + + icon_gray = ach.get("icon_gray", None) + if icon_gray: + icon_gray = f"steam_settings\\img\\{icon_gray}" + else: + icon_gray = 'steam_settings\\img\\steam_default_icon_locked.jpg' + + icongray = ach.get("icongray", None) + if icongray: + icon_gray = f"steam_settings\\img\\{icongray}" + + achs_list.append(f'{ach["name"]} Achieved={icon}') # unlocked + achs_list.append(f'{ach["name"]} Unachieved={icon_gray}') # locked + + formatted_ini = __cdx_ini.format( + cdx_id = appid, + cdx_dlc_list = "\n".join(dlc_list), + cdx_ach_list = "\n".join(achs_list) + ) + + with open(cdx_ini_path, "wt", encoding='utf-8') as f: + f.writelines(formatted_ini) + diff --git a/scripts/external_components/safe_name.py b/scripts/external_components/safe_name.py new file mode 100644 index 00000000..e0cbb207 --- /dev/null +++ b/scripts/external_components/safe_name.py @@ -0,0 +1,22 @@ + +import re + + +ALLOWED_CHARS = set([ + '`', '~', '!', '@', + '#', '$', '%', '&', + '(', ')', '-', '_', + '=', '+', '[', '{', + ']', '}', ';', '\'', + ',', '.', ' ', '\t', + '®', '™', +]) + +def create_safe_name(app_name : str): + safe_name = ''.join(c for c in f'{app_name}' if c.isalnum() or c in ALLOWED_CHARS)\ + .rstrip()\ + .rstrip('.')\ + .replace('\t', ' ') + safe_name = re.sub('\s\s+', ' ', safe_name) + return safe_name + diff --git a/scripts/generate_emu_config.py b/scripts/generate_emu_config.py index 3022a0c5..c0f6e95f 100755 --- a/scripts/generate_emu_config.py +++ b/scripts/generate_emu_config.py @@ -1,11 +1,9 @@ - -USERNAME = "" -PASSWORD = "" - -#steam ids with public profiles that own a lot of games -TOP_OWNER_IDS = [76561198028121353, 76561198001237877, 76561198355625888, 76561198001678750, 76561198237402290, 76561197979911851, 76561198152618007, 76561197969050296, 76561198213148949, 76561198037867621, 76561198108581917] - +import pathlib +import time from stats_schema_achievement_gen import achievements_gen +from external_components import ( + ach_watcher_gen, cdx_gen, app_images, app_details, safe_name +) from controller_config_generator import parse_controller_vdf from steam.client import SteamClient from steam.client.cdn import CDNClient @@ -20,59 +18,7 @@ import urllib.request import urllib.error import threading import queue - -prompt_for_unavailable = True - -if len(sys.argv) < 2: - print("\nUsage: {} appid appid appid etc..\n\nExample: {} 480\n".format(sys.argv[0], sys.argv[0])) - exit(1) - -appids = [] -for id in sys.argv[1:]: - appids += [int(id)] - -client = SteamClient() -if not os.path.exists("login_temp"): - os.makedirs("login_temp") -client.set_credential_location("login_temp") - -if (len(USERNAME) == 0 or len(PASSWORD) == 0): - client.cli_login() -else: - result = client.login(USERNAME, password=PASSWORD) - auth_code, two_factor_code = None, None - while result in (EResult.AccountLogonDenied, EResult.InvalidLoginAuthCode, - EResult.AccountLoginDeniedNeedTwoFactor, EResult.TwoFactorCodeMismatch, - EResult.TryAnotherCM, EResult.ServiceUnavailable, - EResult.InvalidPassword, - ): - - if result == EResult.InvalidPassword: - print("invalid password, the password you set is wrong.") - exit(1) - - elif result in (EResult.AccountLogonDenied, EResult.InvalidLoginAuthCode): - prompt = ("Enter email code: " if result == EResult.AccountLogonDenied else - "Incorrect code. Enter email code: ") - auth_code, two_factor_code = input(prompt), None - - elif result in (EResult.AccountLoginDeniedNeedTwoFactor, EResult.TwoFactorCodeMismatch): - prompt = ("Enter 2FA code: " if result == EResult.AccountLoginDeniedNeedTwoFactor else - "Incorrect code. Enter 2FA code: ") - auth_code, two_factor_code = None, input(prompt) - - elif result in (EResult.TryAnotherCM, EResult.ServiceUnavailable): - if prompt_for_unavailable and result == EResult.ServiceUnavailable: - while True: - answer = input("Steam is down. Keep retrying? [y/n]: ").lower() - if answer in 'yn': break - - prompt_for_unavailable = False - if answer == 'n': break - - client.reconnect(maxdelay=15) - - result = client.login(USERNAME, PASSWORD, None, auth_code, two_factor_code) +import shutil def get_stats_schema(client, game_id, owner_id): @@ -85,16 +31,18 @@ def get_stats_schema(client, game_id, owner_id): client.send(message) return client.wait_msg(EMsg.ClientGetUserStatsResponse, timeout=5) -def download_achievement_images(game_id, image_names, output_folder): - q = queue.Queue() +def download_achievement_images(game_id : int, image_names : set[str], output_folder : str): + print(f"downloading achievements images inside '{output_folder }', images count = {len(image_names)}") + q : queue.Queue[str] = queue.Queue() def downloader_thread(): while True: name = q.get() - succeeded = False if name is None: q.task_done() return + + succeeded = False for u in ["https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/"]: url = "{}{}/{}".format(u, game_id, name) try: @@ -110,9 +58,10 @@ def download_achievement_images(game_id, image_names, output_folder): print("URLError downloading", url, e.code) if not succeeded: print("error could not download", name) + q.task_done() - num_threads = 20 + num_threads = 50 for i in range(num_threads): threading.Thread(target=downloader_thread, daemon=True).start() @@ -123,34 +72,319 @@ def download_achievement_images(game_id, image_names, output_folder): for i in range(num_threads): q.put(None) q.join() + print("finished downloading achievements images") +#steam ids with public profiles that own a lot of games +# https://steamladder.com/ladder/games/ +# in browser console: +#const links = $x("/html/body/div[3]/table/tbody/tr/td[2]/a[@href]/@href"); +#console.clear(); +#for (let index = 0; index < links.length; index++) { +# const usr_link = links[index].textContent.split('/').filter(s => s); +# const usr_id = usr_link[usr_link.length - 1] +# console.log(usr_id) +#} +TOP_OWNER_IDS = set([ + 76561198213148949, + 76561198108581917, + 76561198028121353, + 76561197979911851, + 76561198355625888, + 76561198237402290, + 76561197969050296, + 76561198152618007, + 76561198001237877, + 76561198037867621, + 76561198001678750, + 76561198217186687, + 76561198094227663, + 76561197993544755, + 76561197963550511, + 76561198095049646, + 76561197973009892, + 76561197969810632, + 76561198388522904, + 76561198864213876, + # 76561198017975643, + # 76561198044596404, + # 76561197976597747, + # 76561197962473290, + # 76561197976968076, + # 76561198235911884, + # 76561198313790296, + # 76561198407953371, + # 76561198063574735, + # 76561198122859224, + # 76561198154462478, + # 76561197996432822, + # 76561197979667190, + # 76561198139084236, + # 76561198842864763, + # 76561198096081579, + # 76561198019712127, + # 76561198033715344, + # 76561198121398682, + # 76561198027233260, + # 76561198104323854, + # 76561197995070100, + # 76561198001221571, + # 76561198005337430, + # 76561198085065107, + # 76561198027214426, + # 76561198062901118, + # 76561198008181611, + # 76561198124872187, + # 76561198048373585, + # 76561197974742349, + # 76561198040421250, + # 76561198017902347, + # 76561198010615256, + # 76561197970825215, + # 76561198077213101, + # 76561197971011821, + # 76561197992133229, + # 76561197963534359, + # 76561198077248235, + # 76561198152760885, + # 76561198256917957, + # 76561198326510209, + # 76561198019009765, + # 76561198047438206, + # 76561198128158703, + # 76561198037809069, + # 76561198121336040, + # 76561198102767019, + # 76561198063728345, + # 76561198082995144, + # 76561197981111953, + # 76561197995008105, + # 76561198109083829, + # 76561197968410781, + # 76561198808371265, + # 76561198025858988, + # 76561198252374474, + # 76561198382166453, + # 76561198396723427, + # 76561197992548975, + # 76561198134044398, + # 76561198029503957, + # 76561197990233857, + # 76561197971026489, + # 76561197965978376, + # 76561197976796589, + # 76561197994616562, + # 76561197984235967, + # 76561197992967892, + # 76561198097945516, + # 76561198251835488, + # 76561198281128349, + # 76561198044387084, + # 76561198015685843, + # 76561197993312863, + # 76561198020125851, + # 76561198006391846, + # 76561198158932704, + # 76561198039492467, + # 76561198035552258, + # 76561198031837797, + # 76561197982718230, + # 76561198025653291, + # 76561197972951657, + # 76561198269242105, + # 76561198004332929, + # 76561197972378106, + # 76561197962630138, + # 76561198192399786, + # 76561198119667710, + # 76561198120120943, + # 76561198015992850, + # 76561198096632451, + # 76561198008797636, + # 76561198118726910, + # 76561198018254158, + # 76561198061393233, + # 76561198086250077, + # 76561198025391492, + # 76561198050474710, + # 76561197997477460, + # 76561198105279930, + # 76561198026221141, + # 76561198443388781, + # 76561197981228012, + # 76561197986240493, + # 76561198003041763, + # 76561198056971296, + # 76561198072936438, + # 76561198264362271, + # 76561198101049562, + # 76561198831075066, + # 76561197991699268, + # 76561198042965266, + # 76561198019555404, + # 76561198111433283, + # 76561197984010356, + # 76561198427572372, + # 76561198071709714, + # 76561198034213886, + # 76561198846208086, + # 76561197991613008, + # 76561197978640923, + # 76561198009596142, + # 76561199173688191, + # 76561198294806446, + # 76561197992105918, + # 76561198155124847, + # 76561198032614383, + # 76561198051740093, + # 76561198051725954, + # 76561198048151962, + # 76561198172367910, + # 76561198043532513, + # 76561198029532782, + # 76561198106145311, + # 76561198020746864, + # 76561198122276418, + # 76561198844130640, + # 76561198890581618, + # 76561198021180815, + # 76561198046642155, + # 76561197985091630, + # 76561198119915053, + # 76561198318547224, + # 76561198426000196, + # 76561197988052802, + # 76561198008549198, + # 76561198054210948, + # 76561198028011423, + # 76561198026306582, + # 76561198079227501, + # 76561198070220549, + # 76561198034503074, + # 76561198172925593, + # 76561198286209051, + # 76561197998058239, + # 76561198057648189, + # 76561197982273259, + # 76561198093579202, + # 76561198035612474, + # 76561197970307937, + # 76561197996825541, + # 76561197981027062, + # 76561198019841907, + # 76561197970727958, + # 76561197967716198, + # 76561197970545939, + # 76561198315929726, + # 76561198093753361, + # 76561198413266831, + # 76561198045540632, + # 76561198015514779, + # 76561198004532679, + # 76561198080773680, + # 76561198079896896, + # 76561198005299723, + # 76561198337784749, + # 76561198150126284, + # 76561197988445370, + # 76561198258304011, + # 76561198321551799, + # 76561197973701057, + # 76561197973230221, + # 76561198002535276, + # 76561198100306249, + # 76561198116086535, + # 76561197970970678, + # 76561198085238363, + # 76561198007200913, + # 76561198025111129, + # 76561198068747739, + # 76561197970539274, + # 76561198148627568, + # 76561197970360549, + # 76561198098314980, + # 76561197972529138, + # 76561198007403855, + # 76561197977403803, + # 76561198124865933, + # 76561197981323238, + # 76561197960330700, + # 76561198217979953, + # 76561197960366517, + # 76561198044067612, + # 76561197967197052, + # 76561198027066612, + # 76561198072833066, + # 76561198033967307, + # 76561198104561325, + # 76561198272374716, + # 76561197970127197, + # 76561197970257188, + # 76561198026921217, + # 76561198027904347, + # 76561198062469228, + # 76561198026278913, + # 76561197970548935, + # 76561197966617426, + # 76561198356842617, + # 76561198034276722, + # 76561198355953202, + # 76561197986603983, + # 76561197967923946, + # 76561197961542845, + # 76561198121938079, + # 76561197992357639, + # 76561198002536379, + # 76561198017054389, + # 76561198031129658, + # 76561198020728639, +]) -def generate_achievement_stats(client, game_id, output_directory, backup_directory): - achievement_images_dir = os.path.join(output_directory, "achievement_images") - images_to_download = [] - steam_id_list = TOP_OWNER_IDS + [client.steam_id] - for x in steam_id_list: - out = get_stats_schema(client, game_id, x) - if out is not None: - if len(out.body.schema) > 0: - with open(os.path.join(backup_directory, 'UserGameStatsSchema_{}.bin'.format(appid)), 'wb') as f: - f.write(out.body.schema) - achievements, stats = achievements_gen.generate_stats_achievements(out.body.schema, output_directory) - for ach in achievements: - if "icon" in ach: - images_to_download.append(ach["icon"]) - if "icon_gray" in ach: - images_to_download.append(ach["icon_gray"]) - break - else: - pass - # print("no schema", out) +def generate_achievement_stats(client, game_id : int, output_directory, backup_directory) -> list[dict]: + steam_id_list = TOP_OWNER_IDS.copy() + steam_id_list.add(client.steam_id) + stats_schema_found = None + print(f"finding achievements stats...") + for id in steam_id_list: + #print(f"finding achievements stats using account ID {id}...") + out = get_stats_schema(client, game_id, id) + if out is not None and len(out.body.schema) > 0: + stats_schema_found = out + #print(f"found achievement stats using account ID {id}") + break - if (len(images_to_download) > 0): + if stats_schema_found is None: # nothing found + print(f"[X] app id {game_id} has not achievements") + return [] + + achievement_images_dir = os.path.join(output_directory, "img") + images_to_download : set[str] = set() + + with open(os.path.join(backup_directory, f'UserGameStatsSchema_{game_id}.bin'), 'wb') as f: + f.write(stats_schema_found.body.schema) + ( + achievements, stats, + copy_default_unlocked_img, copy_default_locked_img + ) = achievements_gen.generate_stats_achievements(stats_schema_found.body.schema, output_directory) + + for ach in achievements: + icon = f"{ach.get('icon', '')}".strip() + if icon: + images_to_download.add(icon) + icon_gray = f"{ach.get('icon_gray', '')}".strip() + if icon_gray: + images_to_download.add(icon_gray) + + if images_to_download: if not os.path.exists(achievement_images_dir): os.makedirs(achievement_images_dir) + if copy_default_unlocked_img: + shutil.copy("steam_default_icon_unlocked.jpg", achievement_images_dir) + if copy_default_locked_img: + shutil.copy("steam_default_icon_locked.jpg", achievement_images_dir) download_achievement_images(game_id, images_to_download, achievement_images_dir) + return achievements + def get_ugc_info(client, published_file_id): return client.send_um_and_wait('PublishedFile.GetDetails#1', { 'publishedfileids': [published_file_id], @@ -201,11 +435,11 @@ def get_inventory_info(client, game_id): }) def generate_inventory(client, game_id): - inventory = get_inventory_info(client, appid) + inventory = get_inventory_info(client, game_id) if inventory.header.eresult != EResult.OK: return None - url = "https://api.steampowered.com/IGameInventory/GetItemDefArchive/v0001?appid={}&digest={}".format(game_id, inventory.body.digest) + url = f"https://api.steampowered.com/IGameInventory/GetItemDefArchive/v0001?appid={game_id}&digest={inventory.body.digest}" try: with urllib.request.urlopen(url) as response: return response.read() @@ -217,140 +451,416 @@ def generate_inventory(client, game_id): def get_dlc(raw_infos): try: - try: - dlc_list = set(map(lambda a: int(a), raw_infos["extended"]["listofdlc"].split(","))) - except: - dlc_list = set() + dlc_list = set() depot_app_list = set() + all_depots = set() + try: + dlc_list = set(map(lambda a: int(f"{a}".strip()), raw_infos["extended"]["listofdlc"].split(","))) + except Exception: + dlc_list = set() + if "depots" in raw_infos: - depots = raw_infos["depots"] + depots : dict[str, object] = raw_infos["depots"] for dep in depots: depot_info = depots[dep] if "dlcappid" in depot_info: dlc_list.add(int(depot_info["dlcappid"])) if "depotfromapp" in depot_info: depot_app_list.add(int(depot_info["depotfromapp"])) - return (dlc_list, depot_app_list) - except: + if dep.isnumeric(): + all_depots.add(int(dep)) + + return (dlc_list, depot_app_list, all_depots) + except Exception: print("could not get dlc infos, are there any dlcs ?") - return (set(), set()) + return (set(), set(), set()) -for appid in appids: - backup_dir = os.path.join("backup","{}".format(appid)) - out_dir = os.path.join("{}".format( "{}_output".format(appid)), "steam_settings") +EXTRA_FEATURES: list[tuple[str, str]] = [ + ("disable_account_avatar.txt", "disable avatar functionality."), + ("disable_networking.txt", "disable all networking functionality."), + ("disable_overlay.txt", "disable the overlay."), + ("disable_overlay_achievement_notification.txt", "disable the achievement notifications."), + ("disable_overlay_friend_notification.txt", "disable the friend invite and message notifications."), + ("disable_source_query.txt", "Do not send server details for the server browser. Only works for game servers."), +] - if not os.path.exists(backup_dir): - os.makedirs(backup_dir) - - if not os.path.exists(out_dir): - os.makedirs(out_dir) - - print("outputting config to", out_dir) - - raw = client.get_product_info(apps=[appid]) - game_info = raw["apps"][appid] - - if "common" in game_info: - game_info_common = game_info["common"] - #if "community_visible_stats" in game_info_common: #NOTE: checking this seems to skip stats on a few games so it's commented out - generate_achievement_stats(client, appid, out_dir, backup_dir) - if "supported_languages" in game_info_common: - with open(os.path.join(out_dir, "supported_languages.txt"), 'w') as f: - languages = game_info_common["supported_languages"] - for l in languages: - if "supported" in languages[l] and languages[l]["supported"] == "true": - f.write("{}\n".format(l)) +def disable_all_extra_features(emu_settings_dir : str) -> None: + for item in EXTRA_FEATURES: + with open(os.path.join(emu_settings_dir, item[0]), 'wt', encoding='utf-8') as f: + f.write(item[1]) - with open(os.path.join(out_dir, "steam_appid.txt"), 'w') as f: - f.write(str(appid)) +def help(): + exe_name = os.path.basename(sys.argv[0]) + print(f"\nUsage: {exe_name} [-shots] [-thumbs] [-vid] [-imgs] [-name] [-cdx] [-aw] [-clean] appid appid appid ... ") + print(f" Example: {exe_name} 421050 420 480") + print(f" Example: {exe_name} -shots -thumbs -vid -imgs -name -cdx -aw -clean 421050") + print("\nSwitches:") + print(" -shots: download screenshots for each app if they're available") + print(" -thumbs: download screenshots thumbnails for each app if they're available") + print(" -vid: download the first video available for each app: trailer, gameplay, announcement, etc...") + print(" -imgs: download common images for each app: Steam generated background, icon, logo, etc...") + print(" -name: save the output of each app in a folder with the same name as the app, unsafe characters are discarded") + print(" -cdx: generate .ini file for CODEX Steam emu for each app") + print(" -aw: generate schemas of all possible languages for Achievement Watcher") + print(" -clean: delete any folder/file with the same name as the output before generating any data") + print("\nAll switches are optional except app id, at least 1 app id must be provided\n") - if "depots" in game_info: - if "branches" in game_info["depots"]: - if "public" in game_info["depots"]["branches"]: - if "buildid" in game_info["depots"]["branches"]["public"]: - buildid = game_info["depots"]["branches"]["public"]["buildid"] - with open(os.path.join(out_dir, "build_id.txt"), 'w') as f: - f.write(str(buildid)) - dlc_config_list = [] - dlc_list, depot_app_list = get_dlc(game_info) - dlc_infos_backup = "" - if (len(dlc_list) > 0): - dlc_raw = client.get_product_info(apps=dlc_list)["apps"] - for dlc in dlc_raw: - try: - dlc_config_list.append((dlc, dlc_raw[dlc]["common"]["name"])) - except: - dlc_config_list.append((dlc, None)) - dlc_infos_backup = json.dumps(dlc_raw, indent=4) +def main(): + USERNAME = "" + PASSWORD = "" - with open(os.path.join(out_dir, "DLC.txt"), 'w', encoding="utf-8") as f: - for x in dlc_config_list: - if (x[1] is not None): - f.write("{}={}\n".format(x[0], x[1])) + DOWNLOAD_SCREESHOTS = False + DOWNLOAD_THUMBNAILS = False + DOWNLOAD_VIDEOS = False + DOWNLOAD_COMMON_IMAGES = False + SAVE_APP_NAME = False + GENERATE_CODEX_INI = False + GENERATE_ACHIEVEMENT_WATCHER_SCHEMAS = False + CLEANUP_BEFORE_GENERATING = False - config_generated = False - if "config" in game_info: - if "steamcontrollerconfigdetails" in game_info["config"]: - controller_details = game_info["config"]["steamcontrollerconfigdetails"] - for id in controller_details: - details = controller_details[id] - controller_type = "" - enabled_branches = "" - if "controller_type" in details: - controller_type = details["controller_type"] - if "enabled_branches" in details: - enabled_branches = details["enabled_branches"] - print(id, controller_type) - out_vdf = download_published_file(client, int(id), os.path.join(backup_dir, controller_type + str(id))) - if out_vdf is not None and not config_generated: - if (controller_type in ["controller_xbox360", "controller_xboxone"] and (("default" in enabled_branches) or ("public" in enabled_branches))): - parse_controller_vdf.generate_controller_config(out_vdf.decode('utf-8'), os.path.join(out_dir, "controller")) - config_generated = True - if "steamcontrollertouchconfigdetails" in game_info["config"]: - controller_details = game_info["config"]["steamcontrollertouchconfigdetails"] - for id in controller_details: - details = controller_details[id] - controller_type = "" - enabled_branches = "" - if "controller_type" in details: - controller_type = details["controller_type"] - if "enabled_branches" in details: - enabled_branches = details["enabled_branches"] - print(id, controller_type) - out_vdf = download_published_file(client, int(id), os.path.join(backup_dir, controller_type + str(id))) + prompt_for_unavailable = True - inventory_data = generate_inventory(client, appid) - if inventory_data is not None: - out_inventory = {} - default_items = {} - inventory = json.loads(inventory_data.rstrip(b"\x00")) - raw_inventory = json.dumps(inventory, indent=4) - with open(os.path.join(backup_dir, "inventory.json"), "w") as f: - f.write(raw_inventory) - for i in inventory: - index = str(i["itemdefid"]) - x = {} - for t in i: - if i[t] is True: - x[t] = "true" - elif i[t] is False: - x[t] = "false" - else: - x[t] = str(i[t]) - out_inventory[index] = x - default_items[index] = 1 + if len(sys.argv) < 2: + help() + sys.exit(1) - out_json_inventory = json.dumps(out_inventory, indent=2) - with open(os.path.join(out_dir, "items.json"), "w") as f: - f.write(out_json_inventory) - out_json_inventory = json.dumps(default_items, indent=2) - with open(os.path.join(out_dir, "default_items.json"), "w") as f: - f.write(out_json_inventory) + appids : set[int] = set() + for appid in sys.argv[1:]: + if f'{appid}'.isnumeric(): + appids.add(int(appid)) + elif f'{appid}'.lower() == '-shots': + DOWNLOAD_SCREESHOTS = True + elif f'{appid}'.lower() == '-thumbs': + DOWNLOAD_THUMBNAILS = True + elif f'{appid}'.lower() == '-vid': + DOWNLOAD_VIDEOS = True + elif f'{appid}'.lower() == '-imgs': + DOWNLOAD_COMMON_IMAGES = True + elif f'{appid}'.lower() == '-name': + SAVE_APP_NAME = True + elif f'{appid}'.lower() == '-cdx': + GENERATE_CODEX_INI = True + elif f'{appid}'.lower() == '-aw': + GENERATE_ACHIEVEMENT_WATCHER_SCHEMAS = True + elif f'{appid}'.lower() == '-clean': + CLEANUP_BEFORE_GENERATING = True + else: + print(f'[X] invalid switch: {appid}') + help() + sys.exit(1) + + if not appids: + print(f'[X] no app id was provided') + help() + sys.exit(1) + + client = SteamClient() + if not os.path.exists("login_temp"): + os.makedirs("login_temp") + client.set_credential_location("login_temp") + + if os.path.isfile("my_login.txt"): + filedata = [''] + with open("my_login.txt", "r") as f: + filedata = f.readlines() + filedata = list(map(lambda s: s.strip().replace("\r", "").replace("\n", ""), filedata)) + filedata = [l for l in filedata if l] + if len(filedata) == 2: + USERNAME = filedata[0] + PASSWORD = filedata[1] + + if (len(USERNAME) == 0 or len(PASSWORD) == 0): + client.cli_login() + else: + result = client.login(USERNAME, password=PASSWORD) + auth_code, two_factor_code = None, None + while result in (EResult.AccountLogonDenied, EResult.InvalidLoginAuthCode, + EResult.AccountLoginDeniedNeedTwoFactor, EResult.TwoFactorCodeMismatch, + EResult.TryAnotherCM, EResult.ServiceUnavailable, + EResult.InvalidPassword, + ): + + if result == EResult.InvalidPassword: + print("invalid password, the password you set is wrong.") + exit(1) + + elif result in (EResult.AccountLogonDenied, EResult.InvalidLoginAuthCode): + prompt = ("Enter email code: " if result == EResult.AccountLogonDenied else + "Incorrect code. Enter email code: ") + auth_code, two_factor_code = input(prompt), None + + elif result in (EResult.AccountLoginDeniedNeedTwoFactor, EResult.TwoFactorCodeMismatch): + prompt = ("Enter 2FA code: " if result == EResult.AccountLoginDeniedNeedTwoFactor else + "Incorrect code. Enter 2FA code: ") + auth_code, two_factor_code = None, input(prompt) + + elif result in (EResult.TryAnotherCM, EResult.ServiceUnavailable): + if prompt_for_unavailable and result == EResult.ServiceUnavailable: + while True: + answer = input("Steam is down. Keep retrying? [y/n]: ").lower() + if answer in 'yn': break + + prompt_for_unavailable = False + if answer == 'n': break + + client.reconnect(maxdelay=15) + + result = client.login(USERNAME, PASSWORD, None, auth_code, two_factor_code) + + for appid in appids: + print(f"********* generating info for app id {appid} *********") + raw = client.get_product_info(apps=[appid]) + game_info : dict = raw["apps"][appid] + + game_info_common : dict = game_info.get("common", {}) + app_name = game_info_common.get("name", "") + app_name_on_disk = f"{appid}" + if app_name: + print(f"App name on store: '{app_name}'") + if SAVE_APP_NAME: + sanitized_name = safe_name.create_safe_name(app_name) + if sanitized_name: + app_name_on_disk = f'{sanitized_name}-{appid}' + else: + app_name = f"Unknown_Steam_app_{appid}" # we need this for later use in the Achievement Watcher + print(f"[X] Couldn't find app name on store") + + root_backup_dir = "backup" + backup_dir = os.path.join(root_backup_dir, f"{appid}") + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + + root_out_dir = "output" + base_out_dir = os.path.join(root_out_dir, app_name_on_disk) + emu_settings_dir = os.path.join(base_out_dir, "steam_settings") + info_out_dir = os.path.join(base_out_dir, "info") + + if CLEANUP_BEFORE_GENERATING: + print("cleaning output folder before generating any data") + base_dir_path = pathlib.Path(base_out_dir) + if base_dir_path.is_file(): + base_dir_path.unlink() + time.sleep(0.05) + elif base_dir_path.is_dir(): + shutil.rmtree(base_dir_path) + time.sleep(0.05) + + while base_dir_path.exists(): + time.sleep(0.05) + + if not os.path.exists(emu_settings_dir): + os.makedirs(emu_settings_dir) + + if not os.path.exists(info_out_dir): + os.makedirs(info_out_dir) + + print(f"output dir: '{base_out_dir}'") + + with open(os.path.join(info_out_dir, "product_info.json"), "wt", encoding='utf-8') as f: + json.dump(game_info, f, ensure_ascii=False, indent=2) + + app_details.download_app_details( + base_out_dir, info_out_dir, + appid, + DOWNLOAD_SCREESHOTS, + DOWNLOAD_THUMBNAILS, + DOWNLOAD_VIDEOS) + + clienticon : str = None + icon : str = None + logo : str = None + logo_small : str = None + achievements : list[dict] = [] + languages : list[str] = [] + app_exe = '' + if game_info_common: + if "clienticon" in game_info_common: + clienticon = f"{game_info_common['clienticon']}" + + if "icon" in game_info_common: + icon = f"{game_info_common['icon']}" + + if "logo" in game_info_common: + logo = f"{game_info_common['logo']}" + + if "logo_small" in game_info_common: + logo_small = f"{game_info_common['logo_small']}" + + #print(f"generating achievement stats") + #if "community_visible_stats" in game_info_common: #NOTE: checking this seems to skip stats on a few games so it's commented out + achievements = generate_achievement_stats(client, appid, emu_settings_dir, backup_dir) + + if "supported_languages" in game_info_common: + langs : dict[str, dict] = game_info_common["supported_languages"] + languages = [lang for lang in langs if langs[lang].get("supported", "").lower() == "true"] + + if languages: + with open(os.path.join(emu_settings_dir, "supported_languages.txt"), 'wt', encoding='utf-8') as f: + for lang in languages: + f.write(f'{lang}\n') + + with open(os.path.join(emu_settings_dir, "steam_appid.txt"), 'w') as f: + f.write(str(appid)) + + if "depots" in game_info: + if "branches" in game_info["depots"]: + if "public" in game_info["depots"]["branches"]: + if "buildid" in game_info["depots"]["branches"]["public"]: + buildid = game_info["depots"]["branches"]["public"]["buildid"] + with open(os.path.join(emu_settings_dir, "build_id.txt"), 'wt', encoding='utf-8') as f: + f.write(str(buildid)) + + dlc_config_list : list[tuple[int, str]] = [] + dlc_list, depot_app_list, all_depots = get_dlc(game_info) + dlc_raw = {} + if dlc_list: + dlc_raw = client.get_product_info(apps=dlc_list)["apps"] + for dlc in dlc_raw: + dlc_name = '' + try: + dlc_name = f'{dlc_raw[dlc]["common"]["name"]}' + except Exception: + pass + + if not dlc_name: + dlc_name = f"Unknown Steam app {dlc}" + + dlc_config_list.append((dlc, dlc_name)) + + if dlc_config_list: + with open(os.path.join(emu_settings_dir, "DLC.txt"), 'wt', encoding="utf-8") as f: + for x in dlc_config_list: + f.write(f"{x[0]}={x[1]}\n") + + if all_depots: + with open(os.path.join(emu_settings_dir, "depots.txt"), 'wt', encoding="utf-8") as f: + for game_depot in all_depots: + f.write(f"{game_depot}\n") + + config_generated = False + if "config" in game_info: + if "steamcontrollerconfigdetails" in game_info["config"]: + controller_details = game_info["config"]["steamcontrollerconfigdetails"] + for id in controller_details: + details = controller_details[id] + controller_type = "" + enabled_branches = "" + if "controller_type" in details: + controller_type = details["controller_type"] + if "enabled_branches" in details: + enabled_branches = details["enabled_branches"] + print(id, controller_type) + out_vdf = download_published_file(client, int(id), os.path.join(backup_dir, controller_type + str(id))) + if out_vdf is not None and not config_generated: + if (controller_type in ["controller_xbox360", "controller_xboxone"] and (("default" in enabled_branches) or ("public" in enabled_branches))): + parse_controller_vdf.generate_controller_config(out_vdf.decode('utf-8'), os.path.join(emu_settings_dir, "controller")) + config_generated = True + if "steamcontrollertouchconfigdetails" in game_info["config"]: + controller_details = game_info["config"]["steamcontrollertouchconfigdetails"] + for id in controller_details: + details = controller_details[id] + controller_type = "" + enabled_branches = "" + if "controller_type" in details: + controller_type = details["controller_type"] + if "enabled_branches" in details: + enabled_branches = details["enabled_branches"] + print(id, controller_type) + out_vdf = download_published_file(client, int(id), os.path.join(backup_dir, controller_type + str(id))) + if "launch" in game_info["config"]: + launch_configs = game_info["config"]["launch"] + with open(os.path.join(info_out_dir, "launch_config.json"), "wt", encoding='utf-8') as f: + json.dump(launch_configs, f, ensure_ascii=False, indent=2) + + first_app_exe : str = None + prefered_app_exe : str = None + unwanted_app_exes = ["launch", "start", "play", "try", "demo", "_vr",] + for cfg in launch_configs.values(): + if "executable" in cfg: + app_exe = f'{cfg["executable"]}' + + if app_exe.lower().endswith(".exe"): + app_exe = app_exe.replace("\\", "/").split('/')[-1] + if first_app_exe is None: + first_app_exe = app_exe + if all(app_exe.lower().find(unwanted_exe) < 0 for unwanted_exe in unwanted_app_exes): + prefered_app_exe = app_exe + break + + if prefered_app_exe: + app_exe = prefered_app_exe + elif first_app_exe: + app_exe = first_app_exe + + if GENERATE_ACHIEVEMENT_WATCHER_SCHEMAS: + ach_watcher_gen.generate_all_ach_watcher_schemas( + base_out_dir, + appid, + app_name, + app_exe, + achievements, + icon) + + if GENERATE_CODEX_INI: + cdx_gen.generate_cdx_ini( + base_out_dir, + appid, + dlc_config_list, + achievements) + + if DOWNLOAD_COMMON_IMAGES: + app_images.download_app_images( + base_out_dir, + appid, + clienticon, + icon, + logo, + logo_small) + + disable_all_extra_features(emu_settings_dir) + + inventory_data = generate_inventory(client, appid) + if inventory_data is not None: + out_inventory = {} + default_items = {} + inventory = json.loads(inventory_data.rstrip(b"\x00")) + raw_inventory = json.dumps(inventory, indent=4) + with open(os.path.join(backup_dir, "inventory.json"), "w") as f: + f.write(raw_inventory) + for i in inventory: + index = str(i["itemdefid"]) + x = {} + for t in i: + if i[t] is True: + x[t] = "true" + elif i[t] is False: + x[t] = "false" + else: + x[t] = str(i[t]) + out_inventory[index] = x + default_items[index] = 1 + + with open(os.path.join(emu_settings_dir, "items.json"), "wt", encoding='utf-8') as f: + json.dump(out_inventory, f, ensure_ascii=False, indent=2) + + with open(os.path.join(emu_settings_dir, "default_items.json"), "wt", encoding='utf-8') as f: + json.dump(default_items, f, ensure_ascii=False, indent=2) + + with open(os.path.join(backup_dir, "product_info.json"), "wt", encoding='utf-8') as f: + json.dump(game_info, f, ensure_ascii=False, indent=2) + + with open(os.path.join(backup_dir, "dlc_product_info.json"), "wt", encoding='utf-8') as f: + json.dump(dlc_raw, f, ensure_ascii=False, indent=2) + + print(f"######### done for app id {appid} #########\n\n") + +if __name__ == "__main__": + try: + main() + except Exception as e: + print("Unexpected error:") + print(e) + sys.exit(1) - game_info_backup = json.dumps(game_info, indent=4) - with open(os.path.join(backup_dir, "product_info.json"), "w") as f: - f.write(game_info_backup) - with open(os.path.join(backup_dir, "dlc_product_info.json"), "w") as f: - f.write(dlc_infos_backup) diff --git a/scripts/icon/Froyoshark-Enkel-Steam.ico b/scripts/icon/Froyoshark-Enkel-Steam.ico new file mode 100644 index 0000000000000000000000000000000000000000..d0d856f3a45caaaa1aedf060722246bb3d8662a7 GIT binary patch literal 181772 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 literal 0 HcmV?d00001 diff --git a/scripts/rebuild.bat b/scripts/rebuild.bat new file mode 100644 index 00000000..a8396e2b --- /dev/null +++ b/scripts/rebuild.bat @@ -0,0 +1,37 @@ +@echo off + +setlocal +pushd "%~dp0" + +set "venv=.env" +set "out_dir=bin" +set "build_temp_dir=build_tmp" +set "tool_name=generate_emu_config" +set "icon_file=icon\Froyoshark-Enkel-Steam.ico" +set "main_file=generate_emu_config.py" + +if exist "%out_dir%" ( + rmdir /s /q "%out_dir%" +) + +if exist "%build_temp_dir%" ( + rmdir /s /q "%build_temp_dir%" +) + +del /f /q "*.spec" + +call "%venv%\Scripts\activate.bat" + +pyinstaller "%main_file%" --distpath "%out_dir%" -y --clean --onedir --name "%tool_name%" --noupx --console -i "%icon_file%" --workpath "%build_temp_dir%" --collect-submodules "steam" + +copy /y "steam_default_icon_locked.jpg" "%out_dir%\%tool_name%\" +copy /y "steam_default_icon_unlocked.jpg" "%out_dir%\%tool_name%\" + +echo: +echo ============= +echo Built inside : "%out_dir%\" + +:script_end +popd +endlocal + diff --git a/scripts/recreate_venv.bat b/scripts/recreate_venv.bat new file mode 100644 index 00000000..6b4397d9 --- /dev/null +++ b/scripts/recreate_venv.bat @@ -0,0 +1,15 @@ +@echo off + +cd /d "%~dp0" + +set "venv=.env" +set "reqs_file=requirements.txt" + +if exist "%venv%" ( + rmdir /s /q "%venv%" +) + +python -m venv "%venv%" +timeout /t 1 /nobreak +call "%venv%\Scripts\activate.bat" +pip install -r "%reqs_file%" diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 00000000..4c9c3538 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +steam[client] +pyinstaller diff --git a/scripts/stats_schema_achievement_gen/achievements_gen.py b/scripts/stats_schema_achievement_gen/achievements_gen.py index 5c6a6f42..66cc885a 100755 --- a/scripts/stats_schema_achievement_gen/achievements_gen.py +++ b/scripts/stats_schema_achievement_gen/achievements_gen.py @@ -2,6 +2,7 @@ import vdf import sys import os import json +import copy STAT_TYPE_INT = '1' @@ -9,11 +10,13 @@ STAT_TYPE_FLOAT = '2' STAT_TYPE_AVGRATE = '3' STAT_TYPE_BITS = '4' -def generate_stats_achievements(schema, config_directory): +def generate_stats_achievements( + schema, config_directory + ) -> tuple[list[dict], list[dict], bool, bool]: schema = vdf.binary_loads(schema) # print(schema) - achievements_out = [] - stats_out = [] + achievements_out : list[dict] = [] + stats_out : list[dict] = [] for appid in schema: sch = schema[appid] @@ -25,15 +28,19 @@ def generate_stats_achievements(schema, config_directory): for ach_num in achs: out = {} ach = achs[ach_num] - out["hidden"] = '0' + out['hidden'] = 0 for x in ach['display']: value = ach['display'][x] if x == 'name': x = 'displayName' - if x == 'desc': + elif x == 'desc': x = 'description' - if x == 'Hidden': + elif x == 'Hidden' or f'{x}'.lower() == 'hidden': x = 'hidden' + try: + value = int(value) + except Exception as e: + pass out[x] = value out['name'] = ach['name'] if 'progress' in ach: @@ -57,20 +64,39 @@ def generate_stats_achievements(schema, config_directory): stats_out += [out] #print(stat_info[s]) + copy_default_unlocked_img = False + copy_default_locked_img = False + output_ach = copy.deepcopy(achievements_out) + for out_ach in output_ach: + icon = out_ach.get("icon", None) + if icon: + out_ach["icon"] = f"img/{icon}" + else: + out_ach["icon"] = r'img/steam_default_icon_unlocked.jpg' + copy_default_unlocked_img = True + icon_gray = out_ach.get("icon_gray", None) + if icon_gray: + out_ach["icon_gray"] = f"img/{icon_gray}" + else: + out_ach["icon_gray"] = r'img/steam_default_icon_locked.jpg' + copy_default_locked_img = True - output_ach = json.dumps(achievements_out, indent=4) - output_stats = "" + icongray = out_ach.get("icongray", None) + if icongray: + out_ach["icongray"] = f"{icongray}" + + output_stats : list[str] = [] for s in stats_out: default_num = 0 - if (s['type'] == 'int'): + if f"{s['type']}".lower() == 'int': try: default_num = int(s['default']) except ValueError: default_num = int(float(s['default'])) else: default_num = float(s['default']) - output_stats += "{}={}={}\n".format(s['name'], s['type'], default_num) + output_stats.append(f"{s['name']}={s['type']}={default_num}\n") # print(output_ach) # print(output_stats) @@ -78,13 +104,16 @@ def generate_stats_achievements(schema, config_directory): if not os.path.exists(config_directory): os.makedirs(config_directory) - with open(os.path.join(config_directory, "achievements.json"), 'w') as f: - f.write(output_ach) + if output_ach: + with open(os.path.join(config_directory, "achievements.json"), 'wt', encoding='utf-8') as f: + json.dump(output_ach, f, indent=2) - with open(os.path.join(config_directory, "stats.txt"), 'w', encoding='utf-8') as f: - f.write(output_stats) + if output_stats: + with open(os.path.join(config_directory, "stats.txt"), 'wt', encoding='utf-8') as f: + f.writelines(output_stats) - return (achievements_out, stats_out) + return (achievements_out, stats_out, + copy_default_unlocked_img, copy_default_locked_img) if __name__ == '__main__': if len(sys.argv) < 2: diff --git a/scripts/steam_default_icon_locked.jpg b/scripts/steam_default_icon_locked.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7f52ca7998783826754fbb9050ade728235d866c GIT binary patch literal 4002 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