From 97565274d6c41e8a833ba1ffae2341ed9bda57c4 Mon Sep 17 00:00:00 2001 From: Nikolas Date: Fri, 22 Nov 2024 09:30:19 -0800 Subject: [PATCH] update the changelog --- CHANGELOG.md | 1 + harness_utils/steam.py | 6 +- tinytinaswonderland/README.md | 22 ++++ tinytinaswonderland/manifest.yaml | 12 ++ tinytinaswonderland/tinytinaswonderland.py | 145 +++++++++++++++++++++ tinytinaswonderland/utils.py | 61 +++++++++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 tinytinaswonderland/README.md create mode 100644 tinytinaswonderland/manifest.yaml create mode 100644 tinytinaswonderland/tinytinaswonderland.py create mode 100644 tinytinaswonderland/utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bff7a4..f7e8544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Changes are grouped by the date they are merged to the main branch of the reposi ## 2024-11-20 - Add Alan Wake 2 test harness. +- Add Tiny Tina's Wonderlands harness back with updates. ## 2024-11-05 diff --git a/harness_utils/steam.py b/harness_utils/steam.py index 09b8e30..ece087d 100644 --- a/harness_utils/steam.py +++ b/harness_utils/steam.py @@ -57,7 +57,7 @@ def exec_steam_run_command(game_id: int, steam_path=None) -> Popen: """Runs a game using the Steam browser protocol. The `steam_path` argument can be used to specify a specifc path to the Steam executable instead of relying on finding the current installation in the Window's registry. - + To launch a game with provided arguments, see the function `exec_steam_game`. """ @@ -85,12 +85,12 @@ def get_build_id(game_id: int) -> str: """Gets the build ID of a game from the Steam installation directory""" game_folder = Path(get_steamapps_common_path()) / "../" / f"appmanifest_{game_id}.acf" if not game_folder.exists(): - logging.error("Game folder not found") + logging.warning("Game folder not found when looking for game version") return None with open(game_folder, 'r', encoding='utf-8') as file: data = file.read() buildid_match = re.search(r'"buildid"\s*"(\d+)"', data) if buildid_match is not None: return buildid_match.group(1) - logging.error("No 'buildid' found in the file") + logging.warning("No 'buildid' found in the file when looking for game version") return None diff --git a/tinytinaswonderland/README.md b/tinytinaswonderland/README.md new file mode 100644 index 0000000..1876ec6 --- /dev/null +++ b/tinytinaswonderland/README.md @@ -0,0 +1,22 @@ +# Tiny Tina's Wonderlands + +This test launches Tiny Tina's Wonderlands, navigates to the in-game benchmark, and runs it. + +## Prerequisites + +- Python 3.10+ +- Tiny Tina's Wonderlands installed +- Keras OCR service + +## Options + +- `kerasHost`: string representing the IP address of the Keras service. e.x. `0.0.0.0` +- `kerasPort`: string representing the port of the Keras service. e.x. `8080` + +## Output + +report.json +- `resolution`: string representing the resolution the test was run at, formatted as "[width]x[height]", e.x. `1920x1080` +- `start_time`: number representing a timestamp of the test's start time in milliseconds +- `end_time`: number representing a timestamp of the test's end time in milliseconds +- `score` number of seconds to advance one full year in game \ No newline at end of file diff --git a/tinytinaswonderland/manifest.yaml b/tinytinaswonderland/manifest.yaml new file mode 100644 index 0000000..326ba46 --- /dev/null +++ b/tinytinaswonderland/manifest.yaml @@ -0,0 +1,12 @@ +friendly_name: "Tiny Tina's Wonderlands" +executable: "tinytinaswonderland.py" +process_name: "Wonderlands.exe" +# default recording delay to reduce capturing menus during setup, this should be revisited every test bench as loading times may be different +recording_delay: 75 +asset_paths: + - "harness/tinytinaswonderland/run" +options: + - name: kerasHost + type: input + - name: kerasPort + type: input \ No newline at end of file diff --git a/tinytinaswonderland/tinytinaswonderland.py b/tinytinaswonderland/tinytinaswonderland.py new file mode 100644 index 0000000..e434565 --- /dev/null +++ b/tinytinaswonderland/tinytinaswonderland.py @@ -0,0 +1,145 @@ +from pathlib import Path +import time +import pydirectinput as user +import logging +import sys +import os +from utils import read_resolution, get_documents_path, find_latest_result_file +from argparse import ArgumentParser + +sys.path.insert(1, os.path.join(sys.path[0], '..')) + +from harness_utils.output import ( + format_resolution, seconds_to_milliseconds, setup_log_directory, write_report_json, DEFAULT_LOGGING_FORMAT, DEFAULT_DATE_FORMAT) +from harness_utils.process import terminate_processes +from harness_utils.steam import exec_steam_game, get_build_id +from harness_utils.keras_service import KerasService +from harness_utils.artifacts import ArtifactManager, ArtifactType + +SCRIPT_DIRECTORY = Path(__file__).resolve().parent +LOG_DIRECTORY = SCRIPT_DIRECTORY.joinpath("run") +STEAM_GAME_ID = 1286680 +EXECUTABLE = "Wonderlands.exe" + +def setuo_logging(): + """default logging config""" + setup_log_directory(LOG_DIRECTORY) + logging.basicConfig(filename=f'{LOG_DIRECTORY}/harness.log', + format=DEFAULT_LOGGING_FORMAT, + datefmt=DEFAULT_DATE_FORMAT, + level=logging.DEBUG) + console = logging.StreamHandler() + formatter = logging.Formatter(DEFAULT_LOGGING_FORMAT) + console.setFormatter(formatter) + logging.getLogger('').addHandler(console) + + +def start_game() -> any: + """start the game""" + return exec_steam_game(STEAM_GAME_ID, game_params=["-nostartupmovies"]) + + +def run_benchmark(): + """run benchmark""" + start_game() + + t1 = time.time() + optimizing_shaders = kerasService.look_for_word("optimize", interval=1, attempts=10) + if optimizing_shaders: + time.sleep(40) + + # wait for menu to load + time.sleep(20) + + options_present = kerasService.wait_for_word("options", interval=1, timeout=60) + if options_present is None: + raise ValueError("game did not load within time") + + logging.info('Saw the options! we are good to go!') + user.press("down") + time.sleep(0.5) + user.press("down") + time.sleep(0.5) + user.press("enter") + time.sleep(4) + + visuals = kerasService.wait_for_word("visuals", interval=1, timeout=10) + if visuals is None: + raise ValueError("on the wrong menu!") + + am.take_screenshot("graphics_1.png", ArtifactType.CONFIG_IMAGE, "first screenshot of graphics settings") + + user.press("altleft") + time.sleep(0.5) + + am.take_screenshot("graphics_2.png", ArtifactType.CONFIG_IMAGE, "second screenshot of graphics settings") + time.sleep(1) + + for _ in range(18): + user.press("down") + time.sleep(0.5) + + am.take_screenshot("graphics_3.png", ArtifactType.CONFIG_IMAGE, "third screenshot of graphics settings") + + user.press("altleft") + time.sleep(0.5) + + benchmark = kerasService.wait_for_word("benchmark", interval=1, timeout=10) + if benchmark is None: + raise ValueError("could not find benchmark button") + + user.press("down") + time.sleep(0.5) + user.press("enter") + time.sleep(1) + + + t2 = time.time() + logging.info(f"Harness setup took {round((t2 - t1), 2)} seconds") + + result = kerasService.wait_for_word("fps", interval=0.5, timeout=30) + if result is None: + raise ValueError("benchmark didn't start on time or at all") + + benchmark_start = time.time() + time.sleep(110) + result = kerasService.wait_for_word("options", interval=0.5, timeout=30) + if result is None: + raise ValueError("did not detect end of benchmark, should have landed back in main menu") + + benchmark_end = time.time() + logging.info(f"Benchmark took {round((benchmark_end - benchmark_start), 2)} seconds") + terminate_processes("Wonderlands") + return benchmark_start, benchmark_end + + +try: + parser = ArgumentParser() + parser.add_argument("--kerasHost", dest="keras_host", help="Host for Keras OCR service", required=True) + parser.add_argument("--kerasPort", dest="keras_port", help="Port for Keras OCR service", required=True) + args = parser.parse_args() + kerasService = KerasService(args.keras_host, args.keras_port) + am = ArtifactManager(LOG_DIRECTORY) + start_time, end_time = run_benchmark() + height, width = read_resolution() + result = { + "resolution": format_resolution(width, height), + "start_time": seconds_to_milliseconds(start_time), + "end_time": seconds_to_milliseconds(end_time), + "version": get_build_id(STEAM_GAME_ID) + } + + my_documents_path = get_documents_path() + settings_path = Path(my_documents_path, "My Games\Tiny Tina's Wonderlands\Saved\Config\WindowsNoEditor\GameUserSettings.ini") + am.copy_file(settings_path, ArtifactType.CONFIG_TEXT, "settings file") + saved_results_dir = Path(my_documents_path, "My Games\Tiny Tina's Wonderlands\Saved\BenchmarkData") + benchmark_results = find_latest_result_file(str(saved_results_dir)) + am.copy_file(benchmark_results, ArtifactType.RESULTS_TEXT, "results file") + + am.create_manifest() + write_report_json(LOG_DIRECTORY, "report.json", result) +except Exception as e: + logging.error("Something went wrong running the benchmark!") + logging.exception(e) + terminate_processes("Wonderlands") + exit(1) diff --git a/tinytinaswonderland/utils.py b/tinytinaswonderland/utils.py new file mode 100644 index 0000000..629493f --- /dev/null +++ b/tinytinaswonderland/utils.py @@ -0,0 +1,61 @@ +"""tiny tina's wonderlands utils""" +import winreg +import os +import logging +import re + +EXECUTABLE = "Wonderlands.exe" + +def find_latest_result_file(base_path): + """Look for files in the benchmark results path that match the pattern in the regular expression""" + pattern = r"BenchmarkData.*\.txt" + list_of_files = [] + for filename in os.listdir(base_path): + if re.search(pattern, filename, re.IGNORECASE): + list_of_files.append(base_path + '\\' +filename) + latest_file = max(list_of_files, key=os.path.getmtime) + return latest_file + + +def get_documents_path() -> str: + """get my documents path""" + SHELL_FOLDER_KEY = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders' + try: + root_handle = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) + shell_folders_handle = winreg.OpenKeyEx(root_handle, SHELL_FOLDER_KEY) + personal_path_key = winreg.QueryValueEx(shell_folders_handle, 'Personal') + return personal_path_key[0] + finally: + root_handle.Close() + + +def valid_filepath(path: str) -> bool: + """Validate given path is valid and leads to an existing file. A directory + will throw an error, path must be a file""" + if path is None or len(path.strip()) <= 0: + return False + if os.path.isdir(path) is True: + return False + return os.path.isfile(path) + + +def read_resolution() -> tuple[int]: + """read current resolution""" + dest = f"{get_documents_path()}\\My Games\\Tiny Tina's Wonderlands\\Saved\\Config\\WindowsNoEditor\\GameUserSettings.ini" + hpattern = re.compile(r"ResolutionSizeY=(\d*)") + wpattern = re.compile(r"ResolutionSizeX=(\d*)") + h = w = 0 + with open(dest) as fp: + lines = fp.readlines() + for line in lines: + result = hpattern.match(line) + if result is not None: + h = result.group(1) + + result2 = wpattern.match(line) + if result2 is not None: + w = result2.group(1) + if int(h) > 0 and int(w) > 0: + break + logging.info(f"Current resolution is {w}x{h}") + return (h, w)