unifir 102 - A developer’s guide

This document provides an overview of the inner workings of unifir, focusing on how scripts, props, and actions work together to produce a Unity scene. The tone and focus here are going to be extremely technical and fiddly; for a friendlier introduction to unifir 101 - A user’s guide.

At a high level, unifir operates around the idea of a script object, a container that stores all the parameters and instructions you want to run through Unity in a single place. Those parameters and instructions are specified in the form of props, which let users add certain parameterized pre-written commands to their script to execute in sequence. The actual execution is then handled by the action() function, which both prepares the script for execution and then executes it. This basic pattern is modeled after the fantastic recipes package.

With that framework established, let’s walk through what exactly scripts and props are and how they interact with action(). But first, we should talk about waiver().

Waivers

As we’ll discuss in a moment, unifir does a lot of input checking to make sure that scripts are going to execute successfully in an attempt to give users more useful feedback than Unity’s command line interface often offers. That involves making sure that scripts and props have the correct values provided to all of their parameters, and also validating that the user is going to have a working version of Unity to run these commands in at all.

Of course, a short list of places that don’t have a working version of Unity includes “CRAN check machines” and “GitHub actions”. And so as a result, even the simplest function calls will fail on CRAN – for instance, running the following would throw an error:

library(unifir)
make_script("example")

In order to work around this, I’ve borrowed an idea (and the entire function) from ggplot2: waiver(). The waiver() function is a way to indicate to unifir that yes, you know this value normally needs to be provided, and you know that it’s missing, and that’s fine. All waiver() does is return an object of class waiver:

waiver()
#> list()
#> attr(,"class")
#> [1] "waiver"

On its own, this object doesn’t do anything. However, in a few key places, unifir will understand waiver() as meaning “don’t validate this argument”, which is essential for some odd props and CRAN checks to succeed. I’ll be using it throughout this vignette, starting with:

Scripts

The “script” is the core object in unifir. To create a script, we use make_script:

script <- make_script(
  project = file.path(tempdir(), "unifir"),
  unity = waiver() # Don't error if we can't find Unity
)
script
#> A `unifir_script` object with 0 props
#> 
#> [1] name type
#> <0 rows> (or 0-length row.names)

This creates an R6 object of the class unifir_script. If you haven’t worked with R6 before, I recommend checking out the section of Advanced R on the subject. At the end of the day, however, a script is effectively just a glorified list. We can check out its contents using names():

names(script)
#>  [1] ".__enclos_env__"    "using"              "beats"             
#>  [4] "props"              "initialize_project" "unity"             
#>  [7] "scene_exists"       "scene_name"         "script_name"       
#> [10] "project"            "clone"              "initialize"

Some of these contents – .__encols_env__, clone, initialize – will be familiar to anyone used to working with R6. Others, such as the file names for the scene and script unifir is operating on, are set in make_script() (and documented in ?make_script) and default to NULL if not set:

all(
  is.null(script$initialize_project),
  is.null(script$scene_name),
  is.null(script$script_name)
)
#> [1] TRUE

Others, like using, beats, and props, are a little bit more involved. We’ll use these objects in order to track the props we add to our script; to talk about that, it’s time we talk about props.

Props

At a low level, a unifir prop is just another R6 object, created using the function unifir_prop():

prop_file <- tempfile()
file.create(prop_file)
#> [1] TRUE

prop <- unifir_prop(
  prop_file = prop_file,
  method_name = "ExampleName",
  method_type = "ExampleMethod",
  build = function(script, prop, debug) {},
  using = "ExampleDependencies",
  parameters = list()
)
prop
#> <unifir_prop>
#>   Public:
#>     build: function (script, prop, debug) 
#>     clone: function (deep = FALSE) 
#>     initialize: function (prop_file, method_name, method_type, parameters, build, 
#>     method_name: ExampleName
#>     method_type: ExampleMethod
#>     parameters: list
#>     prop_file: /tmp/Rtmpyy3XlY/file1363643e46fc
#>     using: ExampleDependencies

Just as before, we can see our prop’s contents using names():

names(prop)
#> [1] ".__enclos_env__" "using"           "build"           "parameters"     
#> [5] "method_type"     "method_name"     "prop_file"       "clone"          
#> [9] "initialize"

These fields are all documented in ?unifir_prop.

Prop objects are the actual method unifir uses to translate R inputs into C# methods. A typical prop will take some input parameters, provided either by the prop constructor function (more on that in a moment) or set at the script level, interpolate them into a pre-written C# method, and then add that method to a pile of C# code that will be run in sequence to produce a scene. Of course, the details of this process depend on what exactly the C# code is expected to do.

Most of those details are sorted out by prop constructor functions. Rather than forcing users to use unifir_prop() directly, unifir provides a number of wrappers around this function to add specific props to a script. For instance, if we look at the code that powers our new_scene() function:

new_scene
#> function (script, setup = c("EmptyScene", "DefaultGameObjects"), 
#>     mode = c("Additive", "Single"), method_name = NULL, exec = TRUE) 
#> {
#>     setup <- match.arg(setup)
#>     mode <- match.arg(mode)
#>     prop <- unifir_prop(prop_file = system.file("NewScene.cs", 
#>         package = "unifir"), method_name = method_name, method_type = "NewScene", 
#>         parameters = list(setup = setup, mode = mode), build = function(script, 
#>             prop, debug) {
#>             glue::glue(readChar(prop$prop_file, file.info(prop$prop_file)$size), 
#>                 .open = "%", .close = "%", method_name = prop$method_name, 
#>                 setup = setup, mode = mode)
#>         }, using = c("UnityEngine.SceneManagement", "UnityEditor", 
#>             "UnityEditor.SceneManagement"))
#>     add_prop(script, prop, exec)
#> }
#> <bytecode: 0x55cd4146f478>
#> <environment: namespace:unifir>

This prop takes two parameters – setup and mode – and the top of the script makes sure they exist and are passed correctly. We then get to the internal unifir_prop() call. This call passes system.file("NewScene.cs", package = "unifir") to the prop_file argument; if we print that file out we can see that it’s a relatively simple C# method:

readLines(system.file("NewScene.cs", package = "unifir"))
#> [1] ""                                                                                               
#> [2] "    static void %method_name%() {"                                                              
#> [3] "        var newScene = EditorSceneManager.NewScene(NewSceneSetup.%setup%, NewSceneMode.%mode%);"
#> [4] "    }"

This method calls EditorSceneManager.NewScene, part of Unity’s scripting API, and uses it to create a new scene. Because this code relies on the “UnityEngine.SceneManagement”, “UnityEditor”, and “UnityEditor.SceneManagement” namespaces, we’ve included those namespaces in our prop’s using argument. Our R code is only going to edit three parts of this function, marked by % signs – the method_name, setup, and mode arguments will all be replaced by their equivalent values in R.

Moving further along the unifir_prop call, we can then see that method_name is set to NULL by default. method_name is a unique identifier for this method of this prop, so should not be hard-coded or provided as a default in your prop constructors. If you leave it as NULL, unifir will attempt to generate a name made of 4 random English words to fill in the space.

The next argument, method_type, is internally set to NewScene – users cannot control this value. method_type is meant to be a certain “key” associated with your specific type of prop, which other props might search for if they depend on or conflict with your code; as such, it generally shouldn’t be configurable by your users.

We then see that our function parameters are passed as a list to the parameters argument. These will be essential for our next argument, the build() function, which constructs our C# method. The build() function of every prop must take three (and only three) arguments: script, the unifir script a prop is stored in, prop, the prop being built, and debug, which is discussed below. build() methods with more arguments than this will cause errors. As a result, all other parameters you need to construct your C# method must be stored either in the prop or script object, and it is generally easiest to store them in parameters (which is not checked by the R6 class).

The actual build function here is relatively simple, using glue to replace the snippets between % symbols with their R equivalents. If we don’t change any of the default arguments to this function, that means our output C# method will look something like this:

script <- new_scene(script)

script$props[[1]]$build(script, script$props[[1]])
#> static void PathTestRaceFeed() {
#>     var newScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
#> }

The last part of our prop constructor is the add_prop function, which registers the prop as part of our script. Its code is incredibly simple, and mostly deals with creating the script$beats table:

add_prop
#> function (script, prop, exec = TRUE) 
#> {
#>     stopifnot(is.logical(exec))
#>     stopifnot(methods::is(script, "unifir_script"))
#>     idx <- nrow(script$beats) + 1
#>     script$props[[idx]] <- prop
#>     script$beats[idx, ]$idx <- idx
#>     script$beats[idx, ]$name <- prop$method_name
#>     script$beats[idx, ]$type <- prop$method_type
#>     script$beats[idx, ]$exec <- exec
#>     script$using <- c(script$using, prop$using)
#>     script
#> }
#> <bytecode: 0x55cd422ff320>
#> <environment: namespace:unifir>

That table is relatively simple, storing four variables: idx, the order that methods will be executed in, name, the method_name of each method, type, the method_type of each method, and exec, a boolean representing whether or not that method should be called in the final C# script:

script$beats
#>    idx             name     type exec
#> NA   1 PathTestRaceFeed NewScene TRUE

Action

With our props and scripts written, it’s showtime! We can use the action function to transform our R6 objects into an actual C# script, and then execute that script in Unity.

The action() does quite a few things. Namely, it:

  • Checks if the Unity project needs to be created, and creates it if so.
  • Fills in any missing directory or file names that were left as NULL.
  • Calls the build() method of each prop, in order of script$beats$idx, and stores the constructed C# methods back in the script.
  • Creates a “caller” function that will call every method where script$beats$exec is TRUE in sequential order.
  • If write = TRUE, writes a final C# script to file.
  • If exec = TRUE, executes the final C# script in Unity.

Most of this process is internal and doesn’t matter for any prop constructors you write. So long as your prop is idempotent and can be constructed using its own build argument, action() shouldn’t create any issues.

However, if it does cause trouble, action() returns a constructed script object with props replaced by their equivalent C# code. That makes it easy to see how unifir interpreted your build argument; for instance, we can run our example script through action() as so:

script <- make_script(
  project = file.path(tempdir(), "unifir"),
  unity = waiver(), # Don't error if we can't find Unity
  initialize_project = FALSE, # Don't create the project -- so this runs on CRAN
  script_name = "example_script"
)
script <- new_scene(script)
exec_script <- action(
  script,
  exec = FALSE,
  write = TRUE
)

If we’re concerned about how unifir translated our prop code, we can find the rendered C# inside props:

exec_script$props
#> [1] "static void GoldVoiceImagineCome() {\n    var newScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);\n}\n"

To see the entire produced C# script, we need to read the actual script file itself:

readLines(
  file.path(tempdir(), "unifir", "Assets", "Editor", "example_script.cs")
  )
#>  [1] "using UnityEngine.SceneManagement;"                                                              
#>  [2] "using UnityEditor;"                                                                              
#>  [3] "using UnityEditor.SceneManagement; "                                                             
#>  [4] ""                                                                                                
#>  [5] "public class example_script {"                                                                   
#>  [6] "static void GoldVoiceImagineCome() {"                                                            
#>  [7] "    var newScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);"
#>  [8] "}"                                                                                               
#>  [9] ""                                                                                                
#> [10] "    static void MainFunc() {"                                                                    
#> [11] "        GoldVoiceImagineCome();"                                                                 
#> [12] "    }"                                                                                           
#> [13] "}"

Notice how this code matches our prop, with two additions: first, all the namespaces we provided to using are now imported at the top of the script, and second a function MainFunc has been created to call our prop. When executing the C# script, unifir will execute this MainFunc method, which will in turn call each prop once in the order it’s listed in script$beats.

Debug

One last thing to know about unifir is that it is also built with a “debug” mode, in which functions will make no changes to your file system. unifir code checks if it’s running in debug mode using the following code:

function() {
  debug <- FALSE
  if (Sys.getenv("unifir_debugmode") != "" ||
      !is.null(options("unifir_debugmode")$unifir_debugmode)) {
    debug <- TRUE
  }
  debug
}

So if unifir_debugmode is set to any value either as an environment variable or as an option, unifir will avoid writing anything to file or making any changes to a user’s computer.

When action() is called, it will provide the current state of debug to your prop’s build function. For the majority of props, this can be safely ignored; if all your prop does is add C# code to the final script, action() will respect debug on your behalf. However, if your prop makes changes to the file system before the script is actually executed – for instance, by moving prefabs to the project directory or editing configuration files from R – make sure to wrap those sections of your prop in if (!debug)!

Cloning

While I’ve avoided getting too deep into the underlying mechanics of R6 here, there’s one stumbling block that I want to flag for anyone interested in developing with unifir.

The vast majority of objects in R have what’s referred to as “copy-on-modify” semantics. Say for instance you have some object x:

x <- 2
x
#> [1] 2

If you assign x to a new object, y, we’d expect y and x to both have the same value:

y <- x
y == x
#> [1] TRUE

As an optimization on R’s part, not only are these objects both the same value, but they actually both point to the same piece of data on your machine. R only actually creates a new variable, pointing to its own unique data, when you actually modify the object. As a result, when you change the value of x, you don’t in turn change the value of y: R makes a copy when you modify the original object, so they now point to different data:

x <- 1
y == x
#> [1] FALSE

The same is not true for R6 objects, like unifir scripts and props. If you assign a prop to a new object, both of the objects will point to the same data, and changing one object will change them both. This is true whether you change the new object:

other_prop <- prop
other_prop$method_name <- "NewName"
prop$method_name
#> [1] "NewName"

Or the original one:

prop$method_name <- "AnotherName"
other_prop$method_name
#> [1] "AnotherName"

Instead, with R6 objects, we need to make an explicit copy. We can do this using the clone() function inside our prop object, like so:

disconnected_prop <- prop$clone()

This creates an actual disconnected object, with the same values as the original it was cloned from. Now we can make changes to our prop (or script) without impacting any of the other copies:

disconnected_prop$method_name <- "OnlyIGetThisName"
prop$method_name
#> [1] "AnotherName"

Make sure that, if you’re trying to have multiple different versions of a prop or a script, you use clone() in your own code!