├── README.md └── wincast.py /README.md: -------------------------------------------------------------------------------- 1 | 2 | # A simple python wrapper for the RetroArch WindowCast core 3 | 4 | The [WindowCast core](https://forums.libretro.com/t/official-release-thread-for-windowcast-core/40464) for Retroarch is, imo, one of the coolest things to happen in emulation in quite a while - With this core you can now **FINALLY** run the full suite of Shaders and Bezels/Overlays available to Retroarch in any standalone emulator, including the systems that have traditionally suffered in Retroarch like [PCSX2](https://pcsx2.net/), [Dolphin](https://dolphin-emu.org/) and the never-before-supported [xemu](https://xemu.app/). (Yes I know WindowCast has been around for a while now, but I only just discovered it XD) 5 | 6 | The only problem? It's not entirely intuitive on how to leverage it, it seemingly doesn't intergrate nicely or easily with frontends like [Pegasus](https://pegasus-frontend.org/) or [Launchbox](https://www.launchbox-app.com/) and it requires too much manual intervention in terms of loading up games or even exiting when done. 7 | 8 | That's where this simple wrapper comes into the picture \o/ Download here: [here](https://github.com/AmateursPls/wincast/releases/download/release/wincast_v01.zip) 9 | 10 | With this little tool you can simply point a few command-line arguments to the tool and get near-native-core support for your standalone emulators in RetroArch. 11 | 12 | ### Usage in Pegasus-Frontend 13 | 14 | Set the wincast.exe as your launch "emulator" in the metadata file, pass a few command-line arguments (supporting relative or absolute paths for that sweet, sweeeet portability), and voila 15 | 16 | launch: .\\..\\..\\Emulators\\RetroArch\\wincast.exe --emulator ".\\..\\..\\Emulators\\PCSX2\\pcsx2-qtx64-avx2.exe" --retroarch ".\\..\\..\\Emulators\\RetroArch\\retroarch.exe" --rom "{file.path}" 17 | 18 | It's that simple. Unfortunately I don't have Launchbox anymore so can't provide a word-for-word example on that, but iirc it should be as easy as adding wincast.exe as an emulator for your desired platform, passing the arguments to your emulator.exe, retroarch.exe and then passing through `{rom.file}` or whatever it is as the final argument for `--rom` 19 | 20 | It also supports running your more modern standalone Windows games with pixel art like Triangle Strategy: 21 | 22 | wincast.exe --emulator "E:\Games\Windows\Octopath Traveler\Octopath_Traveler.exe" --retroarch "..\retroarch.exe" 23 | 24 | Some effort has been made to handle things intelligently, for example if a game loads a slave process (as Triangle Strategy in that example does), it will still capture the correct window to pass to RetroArch, and when you exit RetroArch, terminate the correct process. 25 | 26 | ### Features 27 | 28 | - Automatically creates the partial.txt files necessary for capturing the correct window to pass through to RetroArch 29 | - When you quit RetroArch, it will also quit the launched Emulator/Game 30 | - Supports "per-core" shader/config settings in RetroArch (by way of leveraging the 'Content Directory" save type) 31 | - Supports per-game shader/config settings in RetroArch 32 | - Can define the directory to use to store the partial-match.txt files by using the `--partialdir` command-line argument - If no argument is passed it will use the location of where wincast.exe is placed, so put it somewhere logical. Personally I created a wincast folder in RetroArch, and keep wincast.exe inside of it. 33 | - Supports relative or absolute paths for all arguments you pass 34 | - Has support for passing command-line arguments to the standalone emulators/games themselves (although this is currently a very crude implementation requiring some extra work) 35 | 36 | ### TODO 37 | - Fix/improve passing command-line arguments to standalone emulators/games 38 | - More intelligent handling of windows and child processes to support opening a game that needs to be opened with a launcher, then loads the *real* window. 39 | - Capturing an xinput controller combination (I'm thinking Up+Select) to pass Ctrl-Alt-T to RetroArch - The only reason I didn't implement this is because it seems I no longer need it after changing the setting advised to change in the Nvidia Control Panel by the WindowCast Readme 40 | - Further testing 41 | - A schite-load of inevitable bugfixing 42 | 43 | ### Output of --help 44 | **usage**: RetroArch WindowCast Wrapper [-h] --emulator EMULATOR --retroarch RETROARCH [--partialdir PARTIALDIR] [--emulator_args EMULATOR_ARGS] [--waitduration WAITDURATION] [--rom ROM] 45 | 46 | optional arguments: 47 | - `-h, --help` show this help message and exit 48 | - `--emulator EMULATOR` The path to the standalone emulator (or game exe) you're running 49 | - `--retroarch RETROARCH` 50 | The path to your retroarch.exe 51 | - `--partialdir PARTIALDIR` 52 | (OPTIONAL) The path to the directory you want to store the - partial txt files 53 | - `--emulator_args` EMULATOR_ARGS 54 | (OPTIONAL) You can use this to pass arguments to the standalone emulator (or game) on launch if you need to for some reason. This is a fairly crude implementation, I wouldn't expect much from it/would expect bugs if I were you. You're much better off just configuring your emulator as necessary in the UI. 55 | - `--waitduration WAITDURATION` 56 | (OPTIONAL) Specifies the time in seconds to wait for standalone emulator to load. Setting to a higher value should assist slow computers and slow HDDs. Default = 5 57 | - `--rom ROM` (OPTIONAL) The path to the rom you're running. If you're launching a game instead of an emulator, don't pass any argument to this. 58 | 59 | ### Warning 60 | I didn't test this nearly as much as I should've. I am **not** a programmer. There **will be** bugs. Your mileage will vary. So far in my limited testing, it has exceeded all expectations, but that doesn't mean much. 61 | 62 | Personally I've had best results using fullscreen 16:9 stretched (despite the advice of the WindowCast readme), and then letting the [HSM MegaBezel](https://forums.libretro.com/t/mega-bezel-reflection-shader-feedback-and-updates/25512) pack downscale it back to 4:3 like is (was?) suggested for the native cores. 63 | 64 | I strongly advise you to read the WindowCast readme thorougly - Particulary the part about the setting to toggle in the NVidia Control Panel if you have an NVidia card. Experiment with the Vulkan, Software and D3D versions of the core. Trial and error is key here right now, to be honest. This tool helps, but it far from solves every edge case. 65 | 66 | ### Thanks 67 | All thanks and credit goes to IHQMD over at the Libretro forums for the WindowCast core. Amazing work by him. 68 | 69 | o7 70 | -------------------------------------------------------------------------------- /wincast.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import win32gui, win32process 3 | from pathlib import Path 4 | from time import sleep 5 | import subprocess 6 | import pathlib 7 | import psutil 8 | import os 9 | 10 | # Returns a parsed Path object based on if absolute or relative 11 | def getParsedPathName(pathName): 12 | if Path(pathName).is_absolute(): 13 | return Path(pathName).absolute() 14 | else: 15 | return Path(currentWorkingDirectory / pathName).absolute() 16 | 17 | # Returns a list of Window Titles from a given Process ID 18 | # Code adapted from the following Stack question: 19 | # https://stackoverflow.com/questions/51418928/find-all-window-handles-from-a-subprocess-popen-pid 20 | def getWindowTitlesFromProcessID(pid): 21 | def callback (hwnd, hwnds): 22 | if win32gui.IsWindowVisible(hwnd): 23 | _, found_pid = win32process.GetWindowThreadProcessId(hwnd) 24 | if found_pid == pid: 25 | hwnds.append(hwnd) 26 | 27 | hwnds = [] 28 | win32gui.EnumWindows(callback, hwnds) 29 | windowTitles = [] 30 | for hwnd in hwnds: 31 | windowTitles.append(win32gui.GetWindowText(hwnd)) 32 | return windowTitles 33 | 34 | # Returns the game title from the ROM Path for use in creating our partial txt file 35 | def getGameTitleFromRomPath(romPath, isRPCS3=False): 36 | if isRPCS3: 37 | if "EBOOT.BIN" in commandLineArguments.rom: 38 | return str(romPath.absolute()).rsplit("\\", 4)[1].split("\\", 1)[0] 39 | return str(romPath.absolute()).rsplit(".", 1)[0].rsplit("\\", 1)[1] 40 | 41 | # Parser for accepting the command-line arguments 42 | commandLineParser = ArgumentParser( 43 | prog = "RetroArch WindowCast Wrapper" 44 | ) 45 | 46 | # Define the command-line arguments we will utilise 47 | commandLineParser.add_argument("--emulator", required=True, \ 48 | help="The path to the standalone emulator (or game exe) you're running") 49 | 50 | commandLineParser.add_argument("--retroarch", required=True, \ 51 | help="The path to your retroarch.exe") 52 | 53 | commandLineParser.add_argument("--partialdir", default=None, \ 54 | help="(OPTIONAL) The path to the directory you want to store the partial txt files") 55 | 56 | commandLineParser.add_argument("--emulator_args", default=None, \ 57 | help="(OPTIONAL) You can use this to pass arguments to the standalone emulator (or game) on launch if you need to for some reason. \ 58 | This is a fairly crude implementation, I wouldn't expect much from it/would expect bugs if I were you. \ 59 | You're much better off just configuring your emulator as necessary in the UI.") 60 | 61 | commandLineParser.add_argument("--waitduration", type=int, default=10, \ 62 | help="(OPTIONAL) Specifies the time in seconds to wait for standalone emulator to load. \ 63 | Setting to a higher value should assist slow computers and slow HDDs. \ 64 | Default = 5") 65 | 66 | commandLineParser.add_argument("--rom", default=None, \ 67 | help="(OPTIONAL) The path to the rom you're running. \ 68 | If you're launching a game instead of an emulator, don't pass any argument to this.") 69 | 70 | # Parse the command-line arguments into a usable object 71 | commandLineArguments = commandLineParser.parse_args() 72 | 73 | # Get the directory this utility is being ran from as a Path() 74 | # Ignoring the filthily-chained 1-liner, this can't be the best way to do this can it? Was the best I could find, though. 75 | currentWorkingDirectory = Path(os.path.abspath(".")) 76 | 77 | # Standalone emulator executable as Path instance 78 | emulatorExePath = getParsedPathName(commandLineArguments.emulator) 79 | 80 | # RetroArch executable as Path instance 81 | retroarchExePath = getParsedPathName(commandLineArguments.retroarch) 82 | 83 | # Rom file as Path instance, if undefined at runtime we'll assume we're launching a game instead of an emulator 84 | if commandLineArguments.rom: 85 | romFilePath = getParsedPathName(commandLineArguments.rom) 86 | 87 | # Partial files directory as Path instance 88 | if not commandLineArguments.partialdir: 89 | partialDirPath = currentWorkingDirectory 90 | else: 91 | # Use this path instead if an argument is specified at runtime 92 | partialDirPath = getParsedPathName(commandLineArguments.partialdir) 93 | # If the passed directory for storing the txt files doesn't exist, create it 94 | if not partialDirPath.exists(): 95 | partialDirPath.mkdir(parents=True, exist_ok=True) 96 | 97 | # Null output so we can prevent games/emulators with a console output holding us up 98 | # This might be completely unnecessary, idk tbh 99 | nullOutput = open(os.devnull, "w") 100 | 101 | # Construct our arguments to pass to the emulator 102 | if not commandLineArguments.emulator_args: 103 | if commandLineArguments.rom: 104 | emulatorArgumentsToPass = romFilePath 105 | else: 106 | emulatorArgumentsToPass = '' 107 | else: 108 | # This is a very crude implementation of the arguments and doesn't support the only useful use-case I can think of 109 | # (Assigning per-game configs to support per-game user input settings) 110 | # TODO then actually, I guess 111 | standaloneSwitches = commandLineArguments.emulator_args.split() 112 | emulatorArgumentsToPass = "" 113 | for switch in standaloneSwitches: 114 | emulatorArgumentsToPass += "--{} ".format(switch) 115 | emulatorArgumentsToPass += '"{}"'.format(str(romFilePath)) 116 | 117 | # Run the standalone emulator 118 | emulatorProcess = subprocess.Popen([emulatorExePath, emulatorArgumentsToPass], stderr=nullOutput, stdout=nullOutput) 119 | 120 | # Dump the stdout and stderr of the ran emulator (or game) 121 | nullOutput.close 122 | 123 | # List to hold all window title(s) found from the standalone emulator (or game) 124 | windowTitles = [] 125 | 126 | # Halt here while we wait for a window to appear 127 | firstLoop = True 128 | # A list containing our original process, and if necessary, all child processes 129 | processesToSniff = [emulatorProcess] 130 | while len(windowTitles) == 0: 131 | # This is our second attempt, so start scanning any potential child processes 132 | # This could probably be expanded upon to handle launchers and the such where we know the parent process isn't what we're after 133 | # TODO ^ 134 | if not firstLoop: 135 | parentProcess = psutil.Process(emulatorProcess.pid) 136 | childrenProcesses = parentProcess.children(recursive=True) 137 | for childProcess in childrenProcesses: 138 | processesToSniff.append(childProcess) 139 | # Time (in seconds) to wait for window(s) to load after executing the emulator 140 | sleep(commandLineArguments.waitduration) 141 | for eachProcess in processesToSniff: 142 | windowTitles = getWindowTitlesFromProcessID(eachProcess.pid) 143 | if firstLoop: 144 | firstLoop = False 145 | 146 | # Set the window title we actually want to work with 147 | desiredWindowTitle = windowTitles[0] 148 | 149 | # Track if the running content is RPCS3 to avoid creating a partial file named "EBOOT" 150 | contentIsRPCS3 = False 151 | 152 | # For now we're just going to crudely assume that if there's more than one window we're dealing with RPCS3 153 | # Is anybody even using this for RPCS3 though? I feel like PS3 is after the cutoff for CRT/scanline shenanigans 154 | if len(windowTitles) == 2: 155 | contentIsRPCS3 = True 156 | for window in windowTitles: 157 | if "RPCS3" in window: 158 | pass 159 | else: 160 | # RPCS3 reports the FPS in the window title so we need to trim it down 161 | # For now, going to leave the Game ID (for example [BLUS12345]) in 162 | try: 163 | desiredWindowTitle = window.rsplit(" | ", 1)[1] 164 | except: 165 | # this code is getting very gross, really need to rewrite it already 166 | # Even though this is as flagged as RPCS3 = True, it's actually not, this is to handle Dolphin 167 | desiredWindowTitle = window 168 | 169 | # Grab the folder name of the emulator for cleaner storage of the partial.txt files 170 | # Also enables mimicking "Per Core" shader settings by using Content Directory 171 | if commandLineArguments.rom: 172 | standaloneFolderName = str(emulatorExePath).rsplit("\\", 1)[0].rsplit("\\", 1)[1] 173 | # No rom defined so let's use the generic name 'win32' here to specify we're loading a game not an emulator 174 | else: 175 | standaloneFolderName = "win32" 176 | 177 | # If a ROM is passed at runtime then let's get the title of that ROM 178 | if commandLineArguments.rom: 179 | gameTitleForPartialFile = getGameTitleFromRomPath(romFilePath, contentIsRPCS3) 180 | # No ROM passed so let's just use the exe file name, which is not perfect but it will do. 181 | else: 182 | gameTitleForPartialFile = str(emulatorExePath).rsplit("\\", 1)[1].split(".")[0] 183 | 184 | # The path we'll create to hold our partial txt files 185 | pathToCreate = partialDirPath / standaloneFolderName 186 | 187 | # Check if it already exists 188 | if not pathToCreate.exists(): 189 | pathToCreate.mkdir(parents=True, exist_ok=False) 190 | 191 | # Save the Path reference to the txt file we create to load in Retroarch 192 | partialTextFile = str(Path(pathToCreate / gameTitleForPartialFile)) + ".txt" 193 | 194 | # Write the actual txt file to the directory we created using the Game Title we extracted earlier 195 | with open(partialTextFile, "w", encoding="utf-8") as textFile: 196 | textFile.write(desiredWindowTitle) 197 | 198 | # Get an absolute path to the WindowsCast DLL to be referenced when calling RetroArch 199 | winCastCorePath = Path(str(retroarchExePath).rsplit("\\", 1)[0] + "\\cores\\wgc_libretro.dll") 200 | 201 | retroarchLaunchString = '{} -L "{}" "{}"'.format(retroarchExePath, winCastCorePath, partialTextFile) 202 | 203 | # Launch retroarch with our desired paramaters. Using os.walk cos subprocess.call doesn't wanna play ball. 204 | os.system(retroarchLaunchString) 205 | 206 | for process in processesToSniff: 207 | process.terminate() --------------------------------------------------------------------------------