From 2f405b31d9e637b484fe2c67681cf1a44b129f7d Mon Sep 17 00:00:00 2001 From: kottochii <106819117+kottochii@users.noreply.github.com> Date: Wed, 20 May 2026 13:58:01 +1000 Subject: [PATCH 1/4] Added an updater script, instructions for it, and integrated it into the Intro screen. --- include/ArcadeMachine.h | 1 + src/ArcadeMachine.cpp | 79 +++++++++- updater.py | 311 ++++++++++++++++++++++++++++++++++++++++ updater_readme.md | 23 +++ 4 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 updater.py create mode 100644 updater_readme.md diff --git a/include/ArcadeMachine.h b/include/ArcadeMachine.h index 24fc101..2d4fbce 100644 --- a/include/ArcadeMachine.h +++ b/include/ArcadeMachine.h @@ -107,6 +107,7 @@ class ArcadeMachine void playThothTechIntro(); void playArcadeTeamIntro(); void playSplashKitIntro(); // Draws the Splashkit Productions logo to the screen and fetches new games from Git repo + bool updateGames(); void printConfigs(); void exitProgram(); diff --git a/src/ArcadeMachine.cpp b/src/ArcadeMachine.cpp index 3ccb8df..f0d9e33 100644 --- a/src/ArcadeMachine.cpp +++ b/src/ArcadeMachine.cpp @@ -342,15 +342,84 @@ void ArcadeMachine::playArcadeTeamIntro() */ void ArcadeMachine::playSplashKitIntro() { + using namespace std::string_literals; // Pull the most recent version of the arcade-games repo. - do + constexpr int max_tries = 3; + for(int i = 1; i <= max_tries; i++) { - // Draw SplashKit productions screen + process_events(); + clear_screen(); this->m_introSplashkit.drawTitlePage(); - draw_text("Loading...", COLOR_SLATE_GRAY, "font_text", 60, ARCADE_MACHINE_RES_X / 2 - 100, ARCADE_MACHINE_RES_Y / 2 + 350); + draw_text("Attempting to update ["s+std::to_string(i)+"/"+std::to_string(max_tries)+"]...", COLOR_SLATE_GRAY, "font_text", 60, ARCADE_MACHINE_RES_X / 2 - 100, ARCADE_MACHINE_RES_Y / 2 + 350); refresh_screen(); - - } while (!this->m_config.getFromGit("https://github.com/thoth-tech/arcade-games.git", "games")); + bool updated = this->updateGames(); + if(updated) + { + process_events(); + clear_screen(); + this->m_introSplashkit.drawTitlePage(); + draw_text("Successfully updated", COLOR_SLATE_GRAY, "font_text", 60, ARCADE_MACHINE_RES_X / 2 - 100, ARCADE_MACHINE_RES_Y / 2 + 350); + refresh_screen(); + break; + } + else + { + process_events(); + clear_screen(); + this->m_introSplashkit.drawTitlePage(); + draw_text("Failed to update", COLOR_SLATE_GRAY, "font_text", 60, ARCADE_MACHINE_RES_X / 2 - 100, ARCADE_MACHINE_RES_Y / 2 + 350); + refresh_screen(); + break; + } + } +} + +bool ArcadeMachine::updateGames() +{ + int python_found = std::system("skm python3 --version"); +#ifdef _WIN32 + if (python_found != 0) +#else + if (!(WIFEXITED(python_found) && (WEXITSTATUS(python_found) == 0))) +#endif + { + std::cerr << "[Updater] Python was not found. Please make sure SplashKit is installed and it can work with Python." << std::endl; + return false; + } + + int updater_exit_status = std::system("skm python3 ./updater.py --destination-path=./games/games --updater-info-file=updater_info.json"); +#ifdef _WIN32 + // intentional: WIFEXITED/WIFSIGNALED/WEXITSTATUS don't exist on Windows + int updater_exit_code = updater_exit_status + if(true) +#else + int updater_exit_code = WEXITSTATUS(updater_exit_status); + if(WIFSIGNALED(updater_exit_status)) + { + std::cerr << "[Updater] Python has been terminated by exit code " << WTERMSIG(updater_exit_status) << std::endl; + return false; + } + else if (WIFEXITED(updater_exit_status)) +#endif + { + if(updater_exit_code == 0) + { + // successfully updated + std::cout << ("[Updater] successfully updated the games") << std::endl; + return true; + } + else + { + if(updater_exit_code == 1) + { + std::cerr << "Updater script is not found" << std::endl; + } + // error + std::cerr << ("Could not update, exit code from updater script: ") << updater_exit_code << std::endl; + return false; + } + } + return false; } /** diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..157e87a --- /dev/null +++ b/updater.py @@ -0,0 +1,311 @@ +import json +from posixpath import dirname +import sys +from packaging import version +import platform +from collections import defaultdict +from pathlib import Path +import shutil +import tarfile +import requests +import os + +# Path to the file where updater stores the info on last checked release etc. +# The file MUST be writable +updater_info_path = "./updater.json" +# Destination path +dest_path = './games' +# Github repository to check for updates. Format: "owner/repo" +GITHUB_REPO = "kottochii/thoth-arcade-games" + +def clear_file(fd): + fd.truncate(0) + fd.seek(0) + + +# Downgrading may be allowed with --allow-downgrade. +allow_downgrading = False + +# This may be allowed with --allow-removing-destination-path +# Allows to remove the destination path if it is a file. +allow_removing_destination_path = False + +def profile_platform(platform_title: str): + if platform_title == 'linux': + return "linux" + elif platform_title == 'win32': + return "win" + elif platform_title == 'macos': + return "macos" + else: + return None + +# We are accepting everything compiled for 32-bit from repo +def profile_architecture(architecture_title: str): + arch = platform.machine().lower() + + if "aarch64" in arch or "arm64" in arch: + # return "arm64" + return "arm" + elif "arm" in arch: + return "arm" + elif "x86_64" in arch or "amd64" in arch or "x64" in arch: + # return "x64" + return "x86" + elif "i386" in arch or "i686" in arch or "x86" in arch: + return "x86" + else: + return None + +def file_readable_to_updater(file_name: str): + return file_name.endswith('.tar.gz') + +def download_and_unpack(file_name: str, url: str, destination_path: str|Path): + if file_name.endswith('.tar.gz'): + response = requests.get(url, stream=True) + if response.status_code == 200: + with tarfile.open(fileobj=response.raw, mode='r:gz') as tar: + tar.extractall(path=destination_path) + else: + sys.stderr.write("Error: failed to download from the url " + url + ". Status code: " + str(response.status_code) + "\n") + else: + sys.stderr.write(f"Error: {file_name} is not a .tar.gz file and hence may not be unpacked here") + +def unpack_games(available_games, info_file_contents: dict, destination_path, destination_architecture): + for game_name, assets in available_games.items(): + assets_url, exec_url, assets_hash, exec_hash, assets_name, exec_name = (None,)*6 + if 'assets' in assets: + assets_url = assets['assets']['url'] + assets_name = assets['assets']['name'] + assets_hash = assets['assets']['hash'] + if destination_architecture == 'x64': + if 'x64' in assets: + exec_url = assets['x64']['url'] + exec_name = assets['x64']['name'] + exec_hash = assets['x64']['hash'] + elif 'x86' in assets: + sys.stderr.write("Warning: no x64 version found for the game " + game_name + ". Falling back to x86 version.\n") + exec_url = assets['x86']['url'] + exec_name = assets['x86']['name'] + exec_hash = assets['x86']['hash'] + else: + sys.stderr.write("Warning: no x64 or x86 version found for the game " + game_name + ". Skipping it.\n") + elif destination_architecture == 'arm64': + if 'arm64' in assets: + exec_url = assets['arm64']['url'] + exec_name = assets['arm64']['name'] + exec_hash = assets['arm64']['hash'] + elif 'arm' in assets: + sys.stderr.write("Warning: no arm64 version found for the game " + game_name + ". Falling back to arm version.\n") + exec_url = assets['arm']['url'] + exec_name = assets['arm']['name'] + exec_hash = assets['arm']['hash'] + else: + sys.stderr.write("Warning: no arm or arm64 version found for the game " + game_name + ". Skipping it.\n") + elif destination_architecture == 'x86' or destination_architecture == 'arm': + if destination_architecture in assets: + exec_url = assets[destination_architecture]['url'] + exec_name = assets[destination_architecture]['name'] + exec_hash = assets[destination_architecture]['hash'] + else: + sys.stderr.write("Warning: no " + destination_architecture + " version found for the game " + game_name + ". Skipping it.\n") + + def check_file_needs_update(info_file_contents, game_name, file_name, file_hash, file_url): + previous_hash = info_file_contents['files'].get(file_name, None) + if previous_hash is not None and previous_hash == file_hash: + sys.stderr.write(f"{file_name} is identical to the currently installed and may be skipped\n") + return False + else: + info_file_contents['files'][ file_name ] = file_hash + return True + + + # TODO: ensure to not pull two exact same files (check hash sum) + if exec_url is not None: + previous_hash = info_file_contents['files'].get(exec_name, None) + exec_requires_update = False + assets_requires_update = False + + exec_requires_update = check_file_needs_update(info_file_contents, game_name, exec_name, exec_hash, exec_url) + if assets_url is not None: + assets_requires_update = check_file_needs_update(info_file_contents, game_name, assets_name, assets_hash, assets_url) + + # Update both regardless of which one in particular requires the update + if exec_requires_update or assets_requires_update: + end_game_path = Path(destination_path).joinpath('./' + game_name).resolve() + # Before any download and unpack, we would like to wipe the directory of the game itself + if Path(end_game_path).exists(): + shutil.rmtree(end_game_path) + + download_and_unpack(exec_name, exec_url, end_game_path) + if assets_url is not None: + download_and_unpack(assets_name, assets_url, end_game_path) + + else: + sys.stderr.write(f"Skipped {game_name}, as neither assets nor executable required an update\n") + + else: # No executable provided + sys.stderr.write(f"Warning: game {game_name} does not have an executable, so not unpacking assets either. Skipping.\n") + + + +def check_updates(fd, destination_system, destination_architecture, destination_path, allow_downgrading): + file_contents = {} + try: + json_contents = json.load(fd) + if type(json_contents) != dict: + raise json.JSONDecodeError("Invalid content in the updater info file. Expected a JSON object.", doc=str(json_contents), pos=0) + file_contents = json_contents + except json.JSONDecodeError as e: + # Invalid content, treat it as if it isn't there + sys.stderr.write(f"Warning: invalid content in the updater info file. Treating it as if it isn't there. File path: {updater_info_path.__str__()}\n") + # Clearing and initializing the file + clear_file(fd) + fd.write("{}") + + + + response = requests.get("https://api.github.com/repos/" + GITHUB_REPO + "/releases/latest"); + release_info = response.json() + + + # destination path is a file. We don't want to remove it unless required. + if os.path.isfile(destination_path): + if allow_removing_destination_path: + try: + os.unlink(destination_path) + except PermissionError as e: + sys.stderr.write("Attempted to remove the file, but did not have permissions. Refusing to continue.\n") + exit(6) + else: + sys.stderr.write("The destination path is a file. Refusing to continue.\n") + exit(5) + + try: + # Compare the version of the last release with the version of currently installed app. + remote_version = version.parse(release_info["tag_name"]) + # get the current version, default to "v0" + local_version = version.parse(file_contents.get("last_checked_release_id", "v0")) + if local_version > remote_version: + if allow_downgrading: + sys.stderr.write("Warning: the version of currently installed app is higher than the version of last release.\n") + else: + sys.stderr.write("Error: the version of currently installed app is higher than the version of last release. No downgrading allowed. Local version: " + str(local_version) + ", remote version: " + str(remote_version) + "\n") + sys.stderr.write("Error: downgrading may be allowed with --allow-downgrade\n") + exit(3) + elif local_version == remote_version: + sys.stderr.write("The version of currently installed app is the same as the version of last release. No update needed.\n") + exit(0) + + available_games = defaultdict(dict) + + file_contents["last_checked_release_id"] = release_info["tag_name"] + if file_contents.get("files") is None: + file_contents["files"] = {} + + # We got here means we take the new release + for asset in release_info["assets"]: + if not file_readable_to_updater(asset["name"]): + sys.stderr.write(f"Warning: updater does not work with files of the type of {asset["name"]}. Skipping it.\n") + continue + file_name = asset["name"].split(".")[0] or "" + filename_segments = file_name.split("-") or [None] + game_name = filename_segments[0] + executable_os = filename_segments[1] if len(filename_segments) > 1 else "" + architecture = filename_segments[2] if len(filename_segments) > 2 else "" + if executable_os == 'assets': + available_games[game_name]['assets'] = { + 'url': asset['browser_download_url'], + 'hash': (asset['digest'])[len('sha256:'):], + 'name': asset['name'] + } + # Only look for the system that we are on + elif executable_os == destination_system: + if architecture is None: + sys.stderr.write("Warning: no architecture specified for the asset " + asset["name"] + ". Skipping it.\n") + continue + elif architecture in ['x86', 'arm', 'x64','arm64']: + available_games[game_name][architecture] = { + 'url': asset['browser_download_url'], + 'hash': (asset['digest'])[len('sha256:'):], + 'name': asset['name'] + } + else: + sys.stderr.write("Warning: unsupported architecture " + architecture + " for the asset " + asset["name"] + ". Skipping it.\n") + continue + + + # Create the directory for games if it does not exist yet + if not os.path.isdir(destination_path): + try: + Path(destination_path).mkdir(parents=True) + except PermissionError: + sys.stderr.write(f"Could not proceed with creating directory in {destination_path}. Refusing to proceed.\n") + exit(6) + # Unpack available games and assets for the given platform + unpack_games(available_games, file_contents, destination_path, destination_architecture) + + clear_file(fd) + json.dump(file_contents, fd) + except KeyError as e: + sys.stderr.write(f"Error: could not get last release from the GH Releases. Please ensure that repository https://github.com/{GITHUB_REPO}/ exists and has at least one current release.\n") + exit(11) +try: + # TODO: read argument from the input + dest_platform = profile_platform(sys.platform) + if dest_platform is None: + sys.stderr.write("Error: unsupported platform. Please use one of linux, win, macos\n") + exit(12) + + dest_arch = profile_architecture(platform.machine()) + if dest_arch == None: + sys.stderr.write("Error: unsupported platform. Please use one of arm, arm64, x86, x64\n") + exit(8) + + for arg in sys.argv: + if arg == "--allow-downgrade": + allow_downgrading = True + sys.stderr.write("Warning: Downgrading allowed by the command.\n") + continue + + dest_path_prefix = '--destination-path=' + if arg.startswith(dest_path_prefix): + maybe_dest_path = arg[len(dest_path_prefix):] + if len(maybe_dest_path) == 0: + sys.stderr.write("Error: destination path has not been entered\n") + exit(9) + dest_path = maybe_dest_path + + updater_info_file_prefix = '--updater-info-file=' + if arg.startswith(updater_info_file_prefix): + maybe_updater_info_file = arg[len(updater_info_file_prefix):] + if len(maybe_updater_info_file) == 0: + sys.stderr.write("Error: updater info file path has not been entered\n") + exit(10) + updater_info_path = maybe_updater_info_file + + updater_info_path = Path(dirname(__file__)).joinpath(updater_info_path).resolve() + dest_path = Path(dirname(__file__)).joinpath(dest_path).resolve() + + f = None + try: + if not os.path.isfile(updater_info_path): + Path(updater_info_path).touch() + with open(updater_info_path, "r+") as f: + check_updates(f, destination_system=dest_platform, destination_architecture=dest_arch, destination_path=dest_path, allow_downgrading=allow_downgrading) + except PermissionError as e: + sys.stderr.write("Error: no permission to write to the updater info file. File path: " + updater_info_path + "\n") + exit(2) + except KeyboardInterrupt as e: + sys.stderr.write('Interrupting... ') + try: + # close the file in case of being here + f.__exit() + except Exception: + pass + exit(99) + + exit(0) +except KeyboardInterrupt as e: + sys.stderr.write('Interrupting... Some files may not have been closed properly') + exit(99) \ No newline at end of file diff --git a/updater_readme.md b/updater_readme.md new file mode 100644 index 0000000..0d121b0 --- /dev/null +++ b/updater_readme.md @@ -0,0 +1,23 @@ +# Updater for the games for Arcade Games + +In T1 2026, we decided to move to auto-update from GitHub Releases sections, rather than just pulling the entire repository and hoping that the games are compiled for everything in there. For this, two conditions have to be met: +* The Arcade Games repository should consistently update the Releases section, including changing the version (tag) of the releases +* The Arcade Games repository's contents should follow the file structure (otherwise the updater will not be able to correctly unpack the games) + +## Instructions on use of updater +The updater script has to be called via `python3 ${PATH_TO_UPDATER}` (or `skm python3 ${PATH_TO_UPDATER}`, in case if we are more assured that it is installed). The command accepts the following arguments: +* `--allow-downgrade` - allows downgrading to lower versions in the repository (i.e. the maintainer released `v2` but then made `v1` the latest), without this option, the updater will not proceed in this case +* `--destination-path=${DESTINATION_PATH}`, where `${DESTINATION_PATH}` is the path to the directory into which the updater will unpack the games. Default: `$(pwd)/games` +* `--updater-info-file=${UPDATER_INFO_FILE_PATH}`, where `${UPDATER_INFO_FILE_PATH}` is the path to the file that updater will use to store the info about its previous and current actions. Default: `${pwd}/updater.json` The info stored there is: + * the tag of the last pulled release + * the hash sums of the previously pulled files (so the updater does not try to pull the games from new release if neither of the files has been updated) + +## Instructions on packaging the releases +The updater analyses the files in the Release by filtering the names of the files. It is expected that every eligible file there is a `.tar.gz` file (as the updater opens them with Python's built-in tar module). +The naming has to match the following template: +* Every archive with executable should be named `${game_name}-${os}-${architecture}.tar.gz`, and every asset archive `${game_name}-assets.tar.gz`. +* The updater will unpack the contents into `${DESTINATION_PATH}/${game_name}` exactly as they are in the archive, hence: + * asset archive should have `Resources` folder (in most games) and `config.txt` (otherwise, `arcade-machine` will not read it) on the top level of the archive + * executable archive should have `builds` folder on top level and it should contain the executable that the `arcade-machine` will be looking on a given system + +Any files that do not end with `.tar.gz` will be ignored by the script. \ No newline at end of file From f899149ab038ff502a27ac635f0d7d3b92e30e33 Mon Sep 17 00:00:00 2001 From: kottochii <106819117+kottochii@users.noreply.github.com> Date: Wed, 20 May 2026 14:02:25 +1000 Subject: [PATCH 2/4] Fixed the path to the repository --- updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/updater.py b/updater.py index 157e87a..6b7a4fb 100644 --- a/updater.py +++ b/updater.py @@ -16,7 +16,7 @@ # Destination path dest_path = './games' # Github repository to check for updates. Format: "owner/repo" -GITHUB_REPO = "kottochii/thoth-arcade-games" +GITHUB_REPO = "thoth-tech/arcade-games" def clear_file(fd): fd.truncate(0) From e225d58a700235e2236060754207e061bef579cb Mon Sep 17 00:00:00 2001 From: kottochii <106819117+kottochii@users.noreply.github.com> Date: Wed, 20 May 2026 14:04:37 +1000 Subject: [PATCH 3/4] Uncommented the 64-bit archs (were commented for testing purposes) --- updater.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/updater.py b/updater.py index 6b7a4fb..1f3acff 100644 --- a/updater.py +++ b/updater.py @@ -45,13 +45,11 @@ def profile_architecture(architecture_title: str): arch = platform.machine().lower() if "aarch64" in arch or "arm64" in arch: - # return "arm64" - return "arm" + return "arm64" elif "arm" in arch: return "arm" elif "x86_64" in arch or "amd64" in arch or "x64" in arch: - # return "x64" - return "x86" + return "x64" elif "i386" in arch or "i686" in arch or "x86" in arch: return "x86" else: From f5d0087c6ec340c17112d4499df7cc94a5eafce6 Mon Sep 17 00:00:00 2001 From: kottochii <106819117+kottochii@users.noreply.github.com> Date: Wed, 20 May 2026 14:24:47 +1000 Subject: [PATCH 4/4] Added the json config file into .gitignore, caught ConnectionError --- .gitignore | 1 + updater.py | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 275e8b6..caf140e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ games/* **/*.o ArcadeMachine **/.DS_Store +updater.json \ No newline at end of file diff --git a/updater.py b/updater.py index 1f3acff..536947f 100644 --- a/updater.py +++ b/updater.py @@ -60,12 +60,17 @@ def file_readable_to_updater(file_name: str): def download_and_unpack(file_name: str, url: str, destination_path: str|Path): if file_name.endswith('.tar.gz'): - response = requests.get(url, stream=True) - if response.status_code == 200: - with tarfile.open(fileobj=response.raw, mode='r:gz') as tar: - tar.extractall(path=destination_path) - else: - sys.stderr.write("Error: failed to download from the url " + url + ". Status code: " + str(response.status_code) + "\n") + try: + response = requests.get(url, stream=True) + if response.status_code == 200: + with tarfile.open(fileobj=response.raw, mode='r:gz') as tar: + tar.extractall(path=destination_path) + else: + sys.stderr.write("Error: failed to download from the url " + url + ". Status code: " + str(response.status_code) + "\n") + + except ConnectionError: + sys.stderr.write("Failed to connect to the internet. No updates have been pulled. The old game might have been deleted.") + exit(14) else: sys.stderr.write(f"Error: {file_name} is not a .tar.gz file and hence may not be unpacked here") @@ -162,9 +167,12 @@ def check_updates(fd, destination_system, destination_architecture, destination_ fd.write("{}") - - response = requests.get("https://api.github.com/repos/" + GITHUB_REPO + "/releases/latest"); - release_info = response.json() + try: + response = requests.get("https://api.github.com/repos/" + GITHUB_REPO + "/releases/latest"); + release_info = response.json() + except ConnectionError: + sys.stderr.write("Failed to connect to the internet. No updates have been pulled.") + exit(13) # destination path is a file. We don't want to remove it unless required.