Created
March 23, 2024 19:57
-
-
Save Subtixx/7f898a47966b69a78d66a32170ca95da to your computer and use it in GitHub Desktop.
Fix Unreal Engine Linux uses Windows Paths for SteamDeck
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright Epic Games, Inc. All Rights Reserved. | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.IO; | |
using AutomationTool; | |
using UnrealBuildTool; | |
using EpicGames.Core; | |
using UnrealBuildBase; | |
using Microsoft.Extensions.Logging; | |
using static AutomationTool.CommandUtils; | |
public static class SteamDeckSupport | |
{ | |
private static string RSyncPath = "/bin/rsync"; //Path.Combine(Unreal.RootDirectory.FullName, "Engine\\Extras\\ThirdPartyNotUE\\cwrsync\\bin\\rsync.exe"); | |
private static string SSHPath = "/bin/ssh"; //Path.Combine(Unreal.RootDirectory.FullName, "Engine\\Extras\\ThirdPartyNotUE\\cwrsync\\bin\\ssh.exe"); | |
private static string DevKitRSAPath = "/home/subtixx/.config/steamos-devkit/devkit_rsa"; //Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"steamos-devkit\steamos-devkit\devkit_rsa"); | |
static string KnownHostsPath = "/home/subtixx/.ssh/known_hosts"; | |
static string SteamDeckScripts = Path.Combine(Unreal.RootDirectory.FullName, "Engine\\Build\\SteamDeck"); | |
static string DefaultUserName = "deck"; | |
#region Devices | |
public static List<DeviceInfo> GetDevices(UnrealTargetPlatform RuntimePlatform) | |
{ | |
List<DeviceInfo> Devices = new List<DeviceInfo>(); | |
// Look for any Steam Deck devices that are in the Engine ini files. If so lets add them as valid devices | |
// This will be required for matching devices passed into the BuildCookRun command | |
ConfigHierarchy EngineConfig = ConfigCache.ReadHierarchy(ConfigHierarchyType.Engine, null, UnrealTargetPlatform.Win64); | |
List<string> SteamDeckDevices; | |
if (EngineConfig.GetArray("SteamDeck", "SteamDeckDevice", out SteamDeckDevices)) | |
{ | |
// Expected ini format: +SteamDeckDevice=(IpAddr=10.1.33.19,Name=MySteamDeck,UserName=deck) | |
foreach (string DeckDevice in SteamDeckDevices) | |
{ | |
string IpAddr = ConfigHierarchy.GetStructEntry(DeckDevice, "IpAddr", false); | |
string DeviceName = ConfigHierarchy.GetStructEntry(DeckDevice, "Name", false); | |
string ConfigUserName = ConfigHierarchy.GetStructEntry(DeckDevice, "UserName", false); | |
// As of SteamOS version 3.1, "deck" is the required UserName to be used when making a remote connection | |
// to the device. Eventually it will be configurable, so we will allow users to set it via the config. | |
string UserName = string.IsNullOrEmpty(ConfigUserName) ? DefaultUserName : ConfigUserName; | |
if (RuntimePlatform == UnrealTargetPlatform.Win64) | |
{ | |
DeviceName += " (Proton)"; | |
} | |
else if (RuntimePlatform == UnrealTargetPlatform.Linux) | |
{ | |
DeviceName += " (Native Linux)"; | |
} | |
// Name is optional, if its empty/not found lets just use the IpAddr for the Name | |
if (string.IsNullOrEmpty(DeviceName)) | |
{ | |
DeviceName = "[SteamDeck] " + IpAddr; | |
} | |
if (!string.IsNullOrEmpty(IpAddr)) | |
{ | |
// TODO Fix the usage of OSVersion here. We are abusing this and using MS OSVersion to allow Turnkey to be happy | |
DeviceInfo SteamDeck = new DeviceInfo(RuntimePlatform, DeviceName, IpAddr, | |
Environment.OSVersion.Version.ToString(), "SteamDeck", true, true); | |
SteamDeck.PlatformValues["UserName"] = UserName; | |
Devices.Add(SteamDeck); | |
} | |
else | |
{ | |
Logger.LogError("You must specify the 'IpAddr' field to connect to a SteamDeck!"); | |
} | |
} | |
} | |
return Devices; | |
} | |
/** | |
* Get the ipaddr and username of the device from the params and/or device registry | |
*/ | |
public static bool GetDeviceInfo(UnrealTargetPlatform RuntimePlatform, ProjectParams Params, out string IpAddr, out string UserName) | |
{ | |
if (Params.DeviceNames.Count != 1) | |
{ | |
Logger.LogWarning("SteamDeck deployment requires 1, and only, device"); | |
IpAddr = UserName = null; | |
return false; | |
} | |
// look up in the devices in the registry based on the params (commandline -device=) | |
List<DeviceInfo> Devices = GetDevices(RuntimePlatform); | |
// assume DeviceName is an Id (ie IpAddr) | |
string DeviceId = Params.DeviceNames[0]; | |
DeviceInfo Device = Devices.FirstOrDefault(x => x.Id.Equals(DeviceId, StringComparison.InvariantCultureIgnoreCase)); | |
// if the id didn't match, fallback to trying name match | |
if (Device == null) | |
{ | |
Device = Devices.FirstOrDefault(x => x.Name.Equals(DeviceId, StringComparison.InvariantCultureIgnoreCase)); | |
} | |
// if the device is still null, dont assume this is a valid steam deck device | |
if (Device == null) | |
{ | |
IpAddr = UserName = null; | |
return false; | |
} | |
IpAddr = Device.Id; | |
// if -deviceuser was specified, prefer that, otherwise use what's in the registry | |
UserName = Params.DeviceUsername; | |
if (string.IsNullOrEmpty(UserName)) | |
{ | |
UserName = Device?.PlatformValues["UserName"]; | |
} | |
if (string.IsNullOrEmpty(UserName) || string.IsNullOrEmpty(IpAddr)) | |
{ | |
Logger.LogWarning("Unable to discover Ip Adress and Username for your Steamdeck. Use -device to specify device, and if that device is not in your Engine.ini, then you will need to specify -deviceuser= to set the username."); | |
return false; | |
} | |
return true; | |
} | |
#endregion | |
#region Deploying | |
private static string GetRegisterGameScript(ProjectParams Params, UnrealTargetPlatform RuntimePlatform, string GameFolderPath, string GameRunArgs, string MsvsmonVersion) | |
{ | |
FileReference ExePath = Params.GetProjectExeForPlatform(RuntimePlatform); | |
string RelGameExePath = ExePath.MakeRelativeTo(DirectoryReference.Combine(ExePath.Directory, "../../..")).Replace('\\', '/'); | |
string GameId = $"{Params.ShortProjectName}_{RuntimePlatform}"; | |
// create a script that will be copied over to the SteamDeck and ran to register the game | |
// TODO make these easier to customize, vs hard coding the settings. Assume debugging for now, requires the user to have uploaded the required msvsmom/remote debugging stuff | |
// which is done through uploading any game with debugging enabled through the SteamOS Devkit Client | |
string[] LinuxSettings = | |
{ | |
$"\"steam_play\": \"0\"", | |
}; | |
string[] ProtonSettings = | |
{ | |
$"\"steam_play\": \"1\"", | |
$"\"steam_play_debug\": \"1\"", | |
$"\"steam_play_debug_version\": \"{MsvsmonVersion}\"", | |
}; | |
string JoinedSettings = string.Join(", ", RuntimePlatform == UnrealTargetPlatform.Linux ? LinuxSettings : ProtonSettings); | |
string[] Parms = | |
{ | |
$"\"gameid\":\"{GameId}\"", | |
$"\"directory\":\"{GameFolderPath}\"", | |
$"\"argv\":[\"{RelGameExePath} {GameRunArgs}\"]", | |
$"\"settings\": {{{JoinedSettings}}}", | |
}; | |
string JoinedParams = string.Join(", ", Parms); | |
// combine all the settings | |
return $"#!/bin/bash\nrm ~/devkit-game/{GameId}-settings.json\npython3 ~/devkit-utils/steam-client-create-shortcut --parms '{{{JoinedParams}}}'"; | |
} | |
private static string GetRunGameScript(ProjectParams Params, UnrealTargetPlatform RuntimePlatform) | |
{ | |
string GameId = $"{Params.ShortProjectName}_{RuntimePlatform}"; | |
// NOTE this path is setup based on our steam-client-run-game script, if this changes other places need to change | |
return $"#!/bin/bash\npython3 ~/epic-scripts/steam-client-run-game --parms '{{\"gameid\":\"{GameId}\"}}'"; | |
} | |
// Rsync is a Cygwin utility, so convert windows paths to a path that Cygwin Rsync will understand | |
// This will not work with UNC paths | |
public static string FixupHostPath(string HostPath) | |
{ | |
if (HostPlatform.Platform == UnrealTargetPlatform.Win64) | |
{ | |
string CygdrivePath = ""; | |
if (!string.IsNullOrEmpty(HostPath)) | |
{ | |
string FullPath = Path.GetFullPath(HostPath); | |
string RootPath = Path.GetPathRoot(FullPath); | |
CygdrivePath = Path.Combine("/cygdrive", Char.ToLower(FullPath[0]).ToString(), FullPath.Substring(RootPath.Length)); | |
return CygdrivePath.Replace('\\', '/'); | |
} | |
return CygdrivePath; | |
} | |
return HostPath; | |
} | |
private static IProcessResult Rsync(string SourceFolder, string DestFolder, string UserName, string IpAddr, string[] ExcludeList) | |
{ | |
// make a standard set of options to pass to the rsync for auth's -e option | |
string[] AuthOpts = | |
{ | |
SSHPath, | |
$"-o UserKnownHostsFile='{KnownHostsPath}'", | |
$"-o StrictHostKeyChecking=no", | |
$"-i '{DevKitRSAPath}'", | |
}; | |
string AuthOptions = $"-e \"{string.Join(" ", AuthOpts)}\""; | |
// make a set of --exclude options for anything we want to exclude | |
string ExcludeOptions = ""; | |
if (ExcludeList != null) | |
{ | |
ExcludeOptions = string.Join(" ", ExcludeList.Select(x => $"--exclude={x}")); | |
} | |
string[] RsyncOpts = | |
{ | |
// archive, verbose, human readable | |
$"-avh", | |
// ensure certain permissions on the files copied | |
$"--chmod=Du=rwx,Dgo=rx,Fu=rwx,Fog=rx", | |
// standard authorization options | |
AuthOptions, | |
// this is a trick to make sure the target path exists before running the remote rsync command | |
$"--rsync-path=\"mkdir -p {DestFolder} && rsync\"", | |
// copy/update files as needed | |
$"--update", | |
// delete destination files not in source | |
$"--delete", | |
// exclude some files | |
ExcludeOptions, | |
// source path, in cygwin-speak (/cygdrive/c/Program Files.....) | |
$"'{FixupHostPath(SourceFolder)}/'", | |
// destrination in standard 'user@ipaddr:destpath' format | |
$"{UserName}@{IpAddr}:{DestFolder}", | |
}; | |
// Exclude removing the Saved folders to preserve logs and crash data. Though note these will keep filling up with data | |
return CommandUtils.Run(RSyncPath, string.Join(" ", RsyncOpts), ""); | |
} | |
private static IProcessResult SSHCommand(string Command, string UserName, string IpAddr) | |
{ | |
string SSHArgs = $"-i {DevKitRSAPath} {UserName}@{IpAddr}"; | |
return CommandUtils.Run(SSHPath, $"{SSHArgs} \"{Command}\"", ""); | |
} | |
/* Deploying to a steam deck currently does 2 things | |
* | |
* 1) Generates a script CreateShortcutHelper.sh that will register the game on the SteamDeck once uploaded | |
* 2) Uploads the build using rsync to the devkit-game location. Once uploaded it runs the CreateShortcutHelper.sh generated before. | |
*/ | |
public static void Deploy(UnrealTargetPlatform RuntimePlatform, ProjectParams Params, DeploymentContext SC) | |
{ | |
string IpAddr, UserName; | |
if (GetDeviceInfo(RuntimePlatform, Params, out IpAddr, out UserName) == false) | |
{ | |
return; | |
} | |
string GameFolderPath = $"/home/{UserName}/devkit-game/{Params.ShortProjectName}_{RuntimePlatform}"; | |
string GameRunArgs = $"{SC.ProjectArgForCommandLines} {Params.StageCommandline} {Params.RunCommandline}".Replace("\"", "\\\""); | |
if (!File.Exists(DevKitRSAPath)) | |
{ | |
Logger.LogWarning("Unable to find '{DevKitRSAPath}' rsa key needed to deploy to the steam deck. Make sure you've installed the SteamOS Devkit client", DevKitRSAPath); | |
return; | |
} | |
// copy debugger support files if needed | |
string MsvsmonVersion = ""; | |
if (RuntimePlatform == UnrealTargetPlatform.Win64) | |
{ | |
if (DeployMsvsmon(IpAddr, UserName, out MsvsmonVersion) == false) | |
{ | |
return; | |
} | |
} | |
// write a create shortcut and run game script into the stage dir so it will be copied with out project. Note this is required due to the json | |
// strings requiring a single quote, which our run command replaces with a double quote due to a dotnet limitation. | |
string CreateShortcutScriptFileName = "CreateShortcutHelper.sh"; | |
string CreateShortcutScriptFile = Path.Combine(SC.StageDirectory.FullName, CreateShortcutScriptFileName); | |
File.WriteAllText(CreateShortcutScriptFile, GetRegisterGameScript(Params, RuntimePlatform, GameFolderPath, GameRunArgs, MsvsmonVersion)); | |
string RunGameScriptFileName = "RunGameHelper.sh"; | |
string RunGameScriptFile = Path.Combine(SC.StageDirectory.FullName, RunGameScriptFileName); | |
File.WriteAllText(RunGameScriptFile, GetRunGameScript(Params, RuntimePlatform)); | |
// copy the staged directory, skipping over huge debug files that we don't need on the target | |
IProcessResult Result = Rsync(SC.StageDirectory.FullName, GameFolderPath, UserName, IpAddr, new string[] { /*"*.debug", */ "*.pdb", "Saved/" }); | |
if (Result.ExitCode > 0) | |
{ | |
Logger.LogWarning("Failed to rsync the {Arg0} to {GameFolderPath}. Check connection on ip Check connection on ip {UserName}@{IpAddr}", SC.StageDirectory.FullName, GameFolderPath, UserName, IpAddr); | |
return; | |
} | |
// copy any scripts we have SteamDeckScripts to our custom epic script location | |
string DevkitUtilsPath = $"/home/{UserName}/epic-scripts/"; | |
Result = Rsync(SteamDeckScripts, DevkitUtilsPath, UserName, IpAddr, new string[] {}); | |
if (Result.ExitCode > 0) | |
{ | |
Logger.LogWarning("Failed to rsync the {SteamDeckScripts} to {DevkitUtilsPath}. Check connection on ip Check connection on ip {UserName}@{IpAddr}", SteamDeckScripts, DevkitUtilsPath, UserName, IpAddr); | |
return; | |
} | |
// Run the script to register the game with the Deck | |
Result = SSHCommand($"chmod +x {GameFolderPath}/{CreateShortcutScriptFileName} && {GameFolderPath}/{CreateShortcutScriptFileName}", UserName, IpAddr); | |
if (Result.ExitCode > 0) | |
{ | |
Logger.LogWarning("Failed to run the {CreateShortcutScriptFileName}.sh script. Check connection on ip Check connection on ip {UserName}@{IpAddr}", CreateShortcutScriptFileName, UserName, IpAddr); | |
return; | |
} | |
} | |
private static bool DeployMsvsmon(string IpAddr, string UserName, out string MsvsmonVersion) | |
{ | |
// get the path to some compiler's Msvsmon/RemoteDebugger support files to copy over | |
string MsvsmonPath = null; | |
MsvsmonVersion = "2022"; | |
IEnumerable<DirectoryReference> InstallDirs = WindowsExports.TryGetVSInstallDirs(WindowsCompiler.VisualStudio2022); | |
if (InstallDirs == null) | |
{ | |
InstallDirs = WindowsExports.TryGetVSInstallDirs(WindowsCompiler.VisualStudio2019); | |
MsvsmonVersion = "2019"; | |
} | |
if (InstallDirs != null) | |
{ | |
MsvsmonPath = Path.Combine(InstallDirs.First().FullName, "Common7/IDE/Remote Debugger"); | |
} | |
string TargetMsvsmonPath = $"/home/{UserName}/devkit-msvsmon/msvsmon{MsvsmonVersion}"; | |
IProcessResult Result = Rsync(MsvsmonPath, TargetMsvsmonPath, UserName, IpAddr, null); | |
if (Result.ExitCode > 0) | |
{ | |
Logger.LogWarning("Failed to rsync debugger support files to the SteamDeck. Check connection on ip {UserName}@{IpAddr}", UserName, IpAddr); | |
return false; | |
} | |
return true; | |
} | |
#endregion | |
#region Running | |
public static IProcessResult RunClient(UnrealTargetPlatform RuntimePlatform, CommandUtils.ERunOptions ClientRunFlags, string ClientApp, string ClientCmdLine, ProjectParams Params) | |
{ | |
string IpAddr, UserName; | |
if (GetDeviceInfo(RuntimePlatform, Params, out IpAddr, out UserName) == false) | |
{ | |
return null; | |
} | |
string GameFolderPath = $"/home/{UserName}/devkit-game/{Params.ShortProjectName}_{RuntimePlatform}"; | |
string RunGameScriptFileName = "RunGameHelper.sh"; | |
// TODO would be great if we could tail the log while running. Figure out how to cancel/exit app | |
return SSHCommand($"chmod +x {GameFolderPath}/{RunGameScriptFileName} && {GameFolderPath}/{RunGameScriptFileName}", UserName, IpAddr); | |
} | |
#endregion | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment