 c9625e1b35
			
		
	
	c9625e1b35
	
	
	
		
			
			* Saving Princess: initial commit * settings -> options Co-authored-by: Scipio Wright <scipiowright@gmail.com> * settings -> options Co-authored-by: Scipio Wright <scipiowright@gmail.com> * replace RegionData class with List[str] RegionData was only wrapping a List[str], so we can directly use List[str] * world: MultiWorld -> multiworld: MultiWorld * use world's random instead of multiworld's * use state's has_any and has_all where applicable * remove unused StartInventory import * reorder PerGameCommonOptions * fix relative AutoWorld import Co-authored-by: Scipio Wright <scipiowright@gmail.com> * clean up double spaces * local commands -> Local Commands Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> * remove redundant which items section Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> * game info rework * clean up item count redundancy * add game to readme and codeowners * fix get_region_entrance return type * world.multiworld.get -> world.get * add more events added events for the boss kills that open the gate, as well as for system power being restored these only apply if expanded pool is not selected * add client/autoupdater to launcher * reorder commands in game info * update docs with automated installation info * add quick links to doc * Update setup_en.md * remove standalone saving princess client * doc fixes * code improvements and redundant default removal as suggested by @Exempt-Medic this includes the removal of events from the item/location name to id, as well as checking for the player name being ASCII * add option to change launch coammnd the LaunchCommand option is filled to either the executable or wine with the necessary arguments based on Utils.is_windows * simplify valid install check * mod installer improvements now deletes possible existing files before installing the mod * add option groups and presets * add required client version * update docs about cheat items pop-ups items sent directly by the server (such as with starting inventory) now have pop-ups just like any other item * add Steam Input issue to faq * Saving Princess: BRAINOS requires all weapons * Saving Princess: Download dll and patch together Previously, gm-apclientpp.dll was downloaded from its own repo With this update, the dll is instead extracted from the same zip as the game's patch * Saving Princess: Add URI launch support * Saving Princess: goal also requires all weapons given it's past brainos * Saving Princess: update docs automatic connection support was added, docs now reflect this * Saving Princess: extend([item]) -> append(item) * Saving Princess: automatic connection validation also parses the slot, password and host:port into parameters for the game * Saving Princess: change subprocess .run to .Popen This keeps the game from freezing the launcher while it is running --------- Co-authored-by: Scipio Wright <scipiowright@gmail.com> Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
		
			
				
	
	
		
			259 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			259 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| import argparse
 | |
| import zipfile
 | |
| from io import BytesIO
 | |
| 
 | |
| import bsdiff4
 | |
| from datetime import datetime
 | |
| import hashlib
 | |
| import json
 | |
| import logging
 | |
| import os
 | |
| import requests
 | |
| import secrets
 | |
| import shutil
 | |
| import subprocess
 | |
| from tkinter import messagebox
 | |
| from typing import Any, Dict, Set
 | |
| import urllib
 | |
| import urllib.parse
 | |
| 
 | |
| import Utils
 | |
| from .Constants import *
 | |
| from . import SavingPrincessWorld
 | |
| 
 | |
| files_to_clean: Set[str] = {
 | |
|     "D3DX9_43.dll",
 | |
|     "data.win",
 | |
|     "m_boss.ogg",
 | |
|     "m_brainos.ogg",
 | |
|     "m_coldarea.ogg",
 | |
|     "m_escape.ogg",
 | |
|     "m_hotarea.ogg",
 | |
|     "m_hsis_dark.ogg",
 | |
|     "m_hsis_power.ogg",
 | |
|     "m_introarea.ogg",
 | |
|     "m_malakhov.ogg",
 | |
|     "m_miniboss.ogg",
 | |
|     "m_ninja.ogg",
 | |
|     "m_purple.ogg",
 | |
|     "m_space_idle.ogg",
 | |
|     "m_stonearea.ogg",
 | |
|     "m_swamp.ogg",
 | |
|     "m_zzz.ogg",
 | |
|     "options.ini",
 | |
|     "Saving Princess v0_8.exe",
 | |
|     "splash.png",
 | |
|     "gm-apclientpp.dll",
 | |
|     "LICENSE",
 | |
|     "original_data.win",
 | |
|     "versions.json",
 | |
| }
 | |
| 
 | |
| file_hashes: Dict[str, str] = {
 | |
|     "D3DX9_43.dll": "86e39e9161c3d930d93822f1563c280d",
 | |
|     "Saving Princess v0_8.exe": "cc3ad10c782e115d93c5b9fbc5675eaf",
 | |
|     "original_data.win": "f97b80204bd9ae535faa5a8d1e5eb6ca",
 | |
| }
 | |
| 
 | |
| 
 | |
| class UrlResponse:
 | |
|     def __init__(self, response_code: int, data: Any):
 | |
|         self.response_code = response_code
 | |
|         self.data = data
 | |
| 
 | |
| 
 | |
| def get_date(target_asset: str) -> str:
 | |
|     """Provided the name of an asset, fetches its update date"""
 | |
|     try:
 | |
|         with open("versions.json", "r") as versions_json:
 | |
|             return json.load(versions_json)[target_asset]
 | |
|     except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError):
 | |
|         return "2000-01-01T00:00:00Z"  # arbitrary old date
 | |
| 
 | |
| 
 | |
| def set_date(target_asset: str, date: str) -> None:
 | |
|     """Provided the name of an asset and a date, sets it update date"""
 | |
|     try:
 | |
|         with open("versions.json", "r") as versions_json:
 | |
|             versions = json.load(versions_json)
 | |
|             versions[target_asset] = date
 | |
|     except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError):
 | |
|         versions = {target_asset: date}
 | |
|     with open("versions.json", "w") as versions_json:
 | |
|         json.dump(versions, versions_json)
 | |
| 
 | |
| 
 | |
| def get_timestamp(date: str) -> float:
 | |
|     """Parses a GitHub REST API date into a timestamp"""
 | |
|     return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ").timestamp()
 | |
| 
 | |
| 
 | |
| def send_request(request_url: str) -> UrlResponse:
 | |
|     """Fetches status code and json response from given url"""
 | |
|     response = requests.get(request_url)
 | |
|     if response.status_code == 200:  # success
 | |
|         try:
 | |
|             data = response.json()
 | |
|         except requests.exceptions.JSONDecodeError:
 | |
|             raise RuntimeError(f"Unable to fetch data. (status code {response.status_code}).")
 | |
|     else:
 | |
|         data = {}
 | |
|     return UrlResponse(response.status_code, data)
 | |
| 
 | |
| 
 | |
| def update(target_asset: str, url: str) -> bool:
 | |
|     """
 | |
|     Returns True if the data was fetched and installed
 | |
|         (or it was already on the latest version, or the user refused the update)
 | |
|     Returns False if rate limit was exceeded
 | |
|     """
 | |
|     try:
 | |
|         logging.info(f"Checking for {target_asset} updates.")
 | |
|         response = send_request(url)
 | |
|         if response.response_code == 403:  # rate limit exceeded
 | |
|             return False
 | |
|         assets = response.data[0]["assets"]
 | |
|         for asset in assets:
 | |
|             if target_asset in asset["name"]:
 | |
|                 newest_date: str = asset["updated_at"]
 | |
|                 release_url: str = asset["browser_download_url"]
 | |
|                 break
 | |
|         else:
 | |
|             raise RuntimeError(f"Failed to locate {target_asset} amongst the assets.")
 | |
|     except (KeyError, IndexError, TypeError, RuntimeError):
 | |
|         update_error = f"Failed to fetch latest {target_asset}."
 | |
|         messagebox.showerror("Failure", update_error)
 | |
|         raise RuntimeError(update_error)
 | |
|     try:
 | |
|         update_available = get_timestamp(newest_date) > get_timestamp(get_date(target_asset))
 | |
|         if update_available and messagebox.askyesnocancel(f"New {target_asset}",
 | |
|                                                           "Would you like to install the new version now?"):
 | |
|             # unzip and patch
 | |
|             with urllib.request.urlopen(release_url) as download:
 | |
|                 with zipfile.ZipFile(BytesIO(download.read())) as zf:
 | |
|                     zf.extractall()
 | |
|             patch_game()
 | |
|             set_date(target_asset, newest_date)
 | |
|     except (ValueError, RuntimeError, urllib.error.HTTPError):
 | |
|         update_error = f"Failed to apply update."
 | |
|         messagebox.showerror("Failure", update_error)
 | |
|         raise RuntimeError(update_error)
 | |
|     return True
 | |
| 
 | |
| 
 | |
| def patch_game() -> None:
 | |
|     """Applies the patch to data.win"""
 | |
|     logging.info("Proceeding to patch.")
 | |
|     with open(PATCH_NAME, "rb") as patch:
 | |
|         with open("original_data.win", "rb") as data:
 | |
|             patched_data = bsdiff4.patch(data.read(), patch.read())
 | |
|         with open("data.win", "wb") as data:
 | |
|             data.write(patched_data)
 | |
|         logging.info("Done!")
 | |
| 
 | |
| 
 | |
| def is_install_valid() -> bool:
 | |
|     """Checks that the mandatory files that we cannot replace do exist in the current folder"""
 | |
|     for file_name, expected_hash in file_hashes.items():
 | |
|         if not os.path.exists(file_name):
 | |
|             return False
 | |
|         with open(file_name, "rb") as clean:
 | |
|             current_hash = hashlib.md5(clean.read()).hexdigest()
 | |
|         if not secrets.compare_digest(current_hash, expected_hash):
 | |
|             return False
 | |
|     return True
 | |
| 
 | |
| 
 | |
| def install() -> None:
 | |
|     """Extracts all the game files into the mod installation folder"""
 | |
|     logging.info("Mod installation missing or corrupted, proceeding to reinstall.")
 | |
|     # get the cab file and extract it into the installation folder
 | |
|     with open(SavingPrincessWorld.settings.exe_path, "rb") as exe:
 | |
|         # find the cab header
 | |
|         logging.info("Looking for cab archive inside exe.")
 | |
|         cab_found: bool = False
 | |
|         while not cab_found:
 | |
|             cab_found = exe.read(1) == b'M' and exe.read(1) == b'S' and exe.read(1) == b'C' and exe.read(1) == b'F'
 | |
|         exe.read(4)  # skip reserved1, always 0
 | |
|         cab_size: int = int.from_bytes(exe.read(4), "little")  # read size in bytes
 | |
|         exe.seek(-12, 1)  # move the cursor back to the start of the cab file
 | |
|         logging.info(f"Archive found at offset {hex(exe.seek(0, 1))}, size: {hex(cab_size)}.")
 | |
|         logging.info("Extracting cab archive from exe.")
 | |
|         with open("saving_princess.cab", "wb") as cab:
 | |
|             cab.write(exe.read(cab_size))
 | |
| 
 | |
|     # clean up files from previous installations
 | |
|     for file_name in files_to_clean:
 | |
|         if os.path.exists(file_name):
 | |
|             os.remove(file_name)
 | |
| 
 | |
|     logging.info("Extracting files from cab archive.")
 | |
|     if Utils.is_windows:
 | |
|         subprocess.run(["Extrac32", "/Y", "/E", "saving_princess.cab"])
 | |
|     else:
 | |
|         if shutil.which("wine") is not None:
 | |
|             subprocess.run(["wine", "Extrac32", "/Y", "/E", "saving_princess.cab"])
 | |
|         elif shutil.which("7z") is not None:
 | |
|             subprocess.run(["7z", "e", "saving_princess.cab"])
 | |
|         else:
 | |
|             error = "Could not find neither wine nor 7z.\n\nPlease install either the wine or the p7zip package."
 | |
|             messagebox.showerror("Missing package!", f"Error: {error}")
 | |
|             raise RuntimeError(error)
 | |
|     os.remove("saving_princess.cab")  # delete the cab file
 | |
| 
 | |
|     shutil.copyfile("data.win", "original_data.win")  # and make a copy of data.win
 | |
|     logging.info("Done!")
 | |
| 
 | |
| 
 | |
| def launch(*args: str) -> Any:
 | |
|     """Check args, then the mod installation, then launch the game"""
 | |
|     name: str = ""
 | |
|     password: str = ""
 | |
|     server: str = ""
 | |
|     if args:
 | |
|         parser = argparse.ArgumentParser(description=f"{GAME_NAME} Client Launcher")
 | |
|         parser.add_argument("url", type=str, nargs="?", help="Archipelago Webhost uri to auto connect to.")
 | |
|         args = parser.parse_args(args)
 | |
| 
 | |
|         # handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
 | |
|         if args.url:
 | |
|             url = urllib.parse.urlparse(args.url)
 | |
|             if url.scheme == "archipelago":
 | |
|                 server = f'--server="{url.hostname}:{url.port}"'
 | |
|                 if url.username:
 | |
|                     name = f'--name="{urllib.parse.unquote(url.username)}"'
 | |
|                 if url.password:
 | |
|                     password = f'--password="{urllib.parse.unquote(url.password)}"'
 | |
|             else:
 | |
|                 parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
 | |
| 
 | |
|     Utils.init_logging(CLIENT_NAME, exception_logger="Client")
 | |
| 
 | |
|     os.chdir(SavingPrincessWorld.settings.install_folder)
 | |
| 
 | |
|     # check that the mod installation is valid
 | |
|     if not is_install_valid():
 | |
|         if messagebox.askyesnocancel(f"Mod installation missing or corrupted!",
 | |
|                                      "Would you like to reinstall now?"):
 | |
|             install()
 | |
|         # if there is no mod installation, and we are not installing it, then there isn't much to do
 | |
|         else:
 | |
|             return
 | |
| 
 | |
|     # check for updates
 | |
|     if not update(DOWNLOAD_NAME, DOWNLOAD_URL):
 | |
|         messagebox.showinfo("Rate limit exceeded",
 | |
|                             "GitHub REST API limit exceeded, could not check for updates.\n\n"
 | |
|                             "This will not prevent the game from being played if it was already playable.")
 | |
| 
 | |
|     # and try to launch the game
 | |
|     if SavingPrincessWorld.settings.launch_game:
 | |
|         logging.info("Launching game.")
 | |
|         try:
 | |
|             subprocess.Popen(f"{SavingPrincessWorld.settings.launch_command} {name} {password} {server}")
 | |
|         except FileNotFoundError:
 | |
|             error = ("Could not run the game!\n\n"
 | |
|                      "Please check that launch_command in options.yaml or host.yaml is set up correctly.")
 | |
|             messagebox.showerror("Command error!", f"Error: {error}")
 | |
|             raise RuntimeError(error)
 |