partially­disassembled

Engineering blog from Studio Drydock.

Tips for running headless Unity from a script

First published 31 October 2019

This post follows on from Headless automation in Unity. This time we're looking at how to run a Unity automation or build task from a script, rather than manually. This is a step along the way to using Unity with continuous integration.

I'm using node.js/TypeScript for scripting, but these tips apply regardless of your language of choice (e.g., Python, PowerShell, C#, etc).

In this post:

Locating the Unity executable

In the previous post I asked you to manually locate your Unity.exe, which varies depending on the version you are using. Here's a more robust technique, by parsing the ProjectSettings/ProjectVersion.txt file that Unity generates and maintains alongside your project, which looks like this:

m_EditorVersion: 2019.2.6f1
m_EditorVersionWithRevision: 2019.2.6f1 (fe82a0e88406)

Open this file and use a regex to extract the version number, which can then be used to construct a path to the correct version of Unity:

function findUnityExe() {
    // Find path to project directory, relative to this script file (TODO tailor this to your situation)
    let projectPath = path.join(__dirname, '../') // TODO: tailor this so that it locates your project directory 

    // Find ProjectVersion.txt
    let projectVersionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt')

    // Parse ProjectVersion.txt for the Unity version number
    let unityVersionRe = /m_EditorVersion: (.*)/
    let projectVersionMatch = unityVersionRe.exec(fs.readFileSync(projectVersionPath).toString())
    if (!projectVersionMatch)
        throw 'Cannot parse version from ProjectVersion.txt';
    let unityVersion = projectVersionMatch[1]

    // Return Unity.exe from expected install location for Windows or macOS.  
    // TODO add Linux support
    // TODO handle non-Hub installations
    if (process.platform == 'win32')
        return `"C:\\Program Files\\Unity\\Hub\\Editor\\${unityVersion}\\Editor\\Unity.exe"`
    else if (process.platform == 'darwin')
        return `/Applications/Unity/Hub/Editor/${unityVersion}/Unity.app/Contents/MacOS/Unity`       
    else
        throw 'Unsupported platform for Unity'
}

Passing command-line arguments

You will likely want to pass arguments to your automation or build function from your script; one way to do this is with command-line arguments. For example:

public static class CommandLine
{
    public static bool automationFastMode = false;  // -automationFastMode
    public static int automationTimeout = 30;       // -automationTimeout 120

    // Parse the command-line the first time this class is accessed. 
    static CommandLine()
    {
        string[] args = System.Environment.GetCommandLineArgs();
        for (int i = 1; i < args.Length; ++i)
        {
            switch (args[i])
            {
                case "-automationFastMode":
                    automationFastMode = true;                // This argument is just a switch
                    break;
                case "-automationTimeout":
                    automationTimeout = int.Parse(args[++i]); // Read next argument as int value
                    break;
                default:
                    if (!HandleUnityArgument(args, ref i))
                        Debug.LogError($"Unknown command-line argument {args[i]}");
                    break;
            }
        }
    }

    // We need to know how to parse Unity's built-in arguments so that we correctly
    // skip their values
    static bool HandleUnityArgument(string[] args, ref int i)
    {
        switch (args[i].ToLower())
        {
            // Switches (no value)
            case "-batchmode":
            case "-nographics":
            case "-quit":
            case "-usehub":
            case "-hubipc":
            case "-skipupgradedialogs":
                return true;

            // Single value
            case "-logfile":
            case "-executemethod":
            case "-projectpath":
            case "-hubsessionid":
            case "-cloudenvironment":
                ++i;
                return true;
        }

        // Unsupported argument (if you hit this point, just add the argument to the appropriate
        // place above)
        return false;
    }
}

From your script, you can now specify -automationFastMode and -automationTimeout arguments:

function execUnity(method: string, options: { 
    automationFastMode: bool, 
    automationTimeout: int
}) {
    let unityExe = findUnityExe()
    if (!fs.existsSync(paths.unityExe)) {
        throw 'Cannot find Unity executable'

    var cmd = `${unityExe} -batchmode -nographics -quit -logFile - -executeMethod ${method}`
    if (options.automationFastMode)
        cmd += ' -automationFastMode'
    if (options.automationTimeout)
        cmd += ` -automationTimeout ${options.automationTimeout}`
 
    child_process.spawnSync(cmd, {
        cwd: projectPath,
        shell: true
    })
}

Environment variables

Another way to pass information from your script to Unity is with environment variables. This is particularly useful for establishing the build environment parameters from continuous integration. For example, if using GitLab, the current build number is available as CI_PIPELINE_IID.

// Add as part of BatchModeEditorUtility.BuildiOS introduced last blog post
string buildId = System.Environment.GetEnvironmentVariable("CI_PIPELINE_IID");
PlayerSettings.iOS.buildNumber = buildId;

This ensures that iOS build version numbers are always monotonically increasing, which is a requirement for App Store submission.

Kill Unity

Unity only allows a single instance to load a project at once. Ensure that your script kills the Unity process when it is terminated (for example, when a continuous integration job is cancelled). In TypeScript, something like this is needed:

let unityProcess = child_process.spawn(cmd, { // Note: using spawn, not spawnSync
    cwd: projectPath,
    shell: true
});

process.on('SIGINT', () => { process.exit() })
process.on('exit', () => {
    if (process.platform == 'win32')
        child_process.exec(`taskkill /pid ${unityProcess.pid} /f /t`)
    else
        unityProcess.kill()
})

Log parsing

In order for the continuous integration script to report results from the Unity build or automation run, it needs to receive information from the Unity process. The easiest way to do this is through the log (i.e., Debug.Log from Unity).

We've already been passing -logFile - to the Unity command-line, which causes the log text to pipe to stdout; all we need to do is pick it up on the script side:

let messages: string[] = []
unityProcess.stdout.on('data', data => {
    let lines: string[] = data.toString().trimRight().split(/\r?\n/)
    for (var line of lines) {
        console.log(line) // Replicate log to our standard out
        if (line.startsWith('[CI] '))
            messages.push(line)

        // Perform additional log processing here
    }
})

unityProcess.on('close', exitCode => {
    // Report messages and exit code to CI
})

The above example simply replicates the log to stdout so that the CI runner can pick it up, and additionally picks out messages that begin with [CI], so they can be parsed for e.g. Slack reporting.

Now that we're reading each line of the log from our script, we can work around some issues with Unity:

Watch for compile errors

Unity doesn't exit with an error code if there's a script compile error in editor scripts, which in turn can cause assets to import incorrectly. Detect the error message during log parsing and terminate your automation run immediately:

// Insert at "Perform additional log processing here"
let error = /^.*\(\d+,\d+\): error.*$/.exec(line)
if (error != null) {
    throw 'Unity script compilation error detected, killing build.'
}

Watch for successful exit

Unity sometimes crashes on exit. For us this happens semi-regularly on our automation builds, and is non-deterministic. Since we can't get a useful stack trace from the crash or a way to reproduce it usefully, we don't have many options for getting it fixed.

Instead, we watch for when Unity has started exiting, and set a flag so that we don't terminate automation with an error code:

// Insert at "Perform additional log processing here"
let exitMessage = /^Batchmode quit successfully invoked - shutting down.*/.exec(line)
if (exitMessage != null) {
    console.log('Unity successful exit detected, ignoring return code')
    successfulExit = true
}

Watchdog timer

Unity will hang if your game has an infinite loop. Since the automation is running by itself, you won't be there to stop it! Continuous integration runners will automatically time out after enough time, but it can be helpful to detect a hang earlier and terminate the Unity process much faster.

I run a watchdog timer alongside Unity and reset it whenever Unity writes to the log. If more than 15 seconds pass between log messages, we assume that Unity has hung and kill it (and notify the CI runner that there was a problem).

var watchdogReset = false
let watchdogTimer = setInterval(() => {
    if (!watchdogReset) {
        console.log('Watchdog timer expired, Unity was killed')
        killProcess(unityProcess)
    }
    watchdogReset = false
}, 15000)

unityProcess.stdout.on('data', data => {
    watchdogReset = true
})

Next time

I hope this has been useful! Tune in next week for some examples of generating reports and Slack notifications.

Follow either the RSS feed on this page or @AHolkner on Twitter to get a notification when the next chapter is up.