Using Unity’s Postprocessor to “compile” strings

Using Unity’s Postprocessor to “compile” strings

Tl;dr: String parameters are not type-safe. Using Unity’s Postprocessor replace them with automatically generated enums and detect errors at compile-time instead of run-time.

Recently, while working with a Unity plugin, I came across an API which makes use of string parameters. Personally, I am not a huge fan of them but I must admit that they are sometimes a necessary evil. This happens when an item is not available at compile-time but rather is determined at run-time. Their positive aspect is that since they are untyped they allow you to pass in virtually anything you want and it would allow you to compile without any problems. The caveat? If that string refers to a resource that is not found you have no way of knowing this until you happen to do a call to that API. If that happens the results could be unpleasant or even worse they could go through testing undetected until they are in the hands of a player possibly causing unwanted behaviour. So the questions are: Is there a way one can make one’s code a bit more strict by somehow introducing types in strings without losing the flexibility that the latter provide? Can we know at compile time whether our resource is well-referenced so that no (possibly hidden) problems crop up later on at run-time?  As it turns out in my specific case: yes! To be completely honest I am not sure it would work out in more cases and whether it can be in fact generalised but I am sharing it here just in case someone else finds it useful.

In my specific case the plugin is an audio one which, via a string parameter in their API calls, allows me to play a sound clip. In this case the string parameter refers to the sound clip name. Since this is a string which might be used in multiple places a common good practice is to make it a constant string. This latter constant string is then used instead of the actual string literal. In this way we gain a very small but useful advantage: if, for some reason, the sound clip name changes I have to change only one central place instead of having to potentially change more than one file. During development change is the only constant as they say, so being able to do quick changes like this significantly aids development by reducing the number of manual changes one has to do and preventing programmer errors which cannot be detected by the compiler.

So OK: I can actually now do the change manually in one place and my strings are a bit safer, however:

  1. I can still make a mistake in the new name I enter which error won’t be detected until possibly much later on. Moreover, someone might decide to change a sound clip’s name for whatever reason and inadvertently cause havoc in the game.
  2. I have to actually manually change/add/remove different constants for every sound file I rename/add to/remove from the project. We already automated one aspect by using constants. Can we improve upon this so that we automatically update the code?

Fixing the first problem above, the one related to detection, can be achieved by means of Unity’s own asset post-processor methods. More specifically in our case we can make use of:

AssetPostprocessor.OnPostprocessAllAssets

This static method is called by Unity when changes are done in the project’s Asset folder. Therefore, adding/removing/renaming sound files would cause a call to this method to be made by Unity. This is the perfect place to do what we want to do, that is: detect what sound clips we have available when a change is made to the Asset folder. This can be achieved by means of the following code:

var filesPath = string.Format(
    "{0}/Sounds/", 
    Application.dataPath);
var filePaths = Directory
    .GetFiles(filesPath, "*.*", SearchOption.AllDirectories)
    .Where(file =>
        file.ToLower().EndsWith("wav") || 
        file.ToLower().EndsWith("mp3")
    )
    .ToList();
var soundFileNames = filePaths
    .Select(filePath => 
        Path.GetFileNameWithoutExtension(filePath))
    .ToList();

The first line simply generates the path where our sound files are. By using Unity’s dataPath we are simply generating the path “<ProjectPath><ProjectName>/Assets/Sounds”. The second line does a search for all the file paths in this directory and its subdirectories which end with “wav” or “mp3”. In this way we obtain a list of all the file paths we care about. (Other formats can be added with ease in a similar way.)  The third and final line iterates through all the file paths and obtains just their name.

Having now detected all of the file names one might ask: what are we going to do with them? My answer is to use the file names to generate an enumeration which represents the sound file names and hence “typify” our strings! Basically, we are going to do a very simplistic form of code-generation within Unity! This will allow us to automatically update our code without our intervention. We can achieve this simply as follows:

var enumValueSB = new StringBuilder();
foreach (var soundFileName in soundFileNames)
{
    enumValueSB.AppendFormat("\t{0},\n", soundFileName);
}
var fileContents = string.Format(
    "public enum SoundNames\n{{\n{0}}}", 
    enumValueSB.ToString());
File.WriteAllText(
    "Assets/Scripts/Sounds/SoundNames.cs", 
    fileContents);

Here we are basically iterating through all of the file names and generating their corresponding enumeration member. (We are also adding some tabs in order to preserve some code indentation.) Once we have done this we encapsulate all of these into our “SoundNames” enumeration. All of this generates our code which is then dumped into a sound file in the path of our choice. In my case they will go under “Scripts/Sounds/” which I have created forehand. (Of course, generating these folders automatically through code can be done too if required.)

Now all we need to do is actually call the two code snippets above from within “OnPostProcessAllAssets”. From now whenever we do an asset change, Unity will check whether the files in our “Sound” folder have changed and if so it will generate the required enumeration for us automatically! In my case if I add “sound_file_001.mp3”, “sound_file_002.mp3” and “sound_file_003.mp3” the generated script is:

public enum SoundNames
{
    sound_file_001,
    sound_file_002,
    sound_file_003,
}

Now whenever I need to use a particular sound name I can do, for example:

SoundNames.sound_file_001.ToString()

I sincerely believe that while adding ToString() looks a bit ugly at least we are now sure that our code is safer and less prone to human errors by leveraging the compiler’s power. Something to note is that now whenever someone makes a change to our sound files, our project might not even compile. Let us say I rename “sound_file_001” to “sound_file_001b”. Now wherever we have the above code example becomes invalid since our code regenerates the enumeration accordingly. Ideally this is automated as well but for now at least our code is more safe because the compiler will from now on take care of any sound file changes for us.

I have attached the above code in its most basic form here (zip format):

SoundNamesImporter

Unzip it, drop it into your Unity project, create the relevant folders and you are good to go. Feel free to improve upon this code (there are a million ways it can be improved). I hope that this post helps someone speed up their development or think of new ways of exploiting Unity’s PostProcessor. Feel free to share any mistakes and/or improvements by adding comments below.

5 comments

  1. Nice article, Andrew!

    It is a nice and clean approach.
    However, I think that the OnPostprocessAllAssets callback is there on purpose. In fact I cannot see many other reasons to implement that method other than generating new resources or code. Anyway – being understood that I prefer your method for this case – did you consider to use a T4 template (assuming that you are using Visual Studio as IDE)?
    I don’t know how they fit with Unity, but let me post an example.

    Add a new template to your project, call it SoundNames.tt and paste this code:

    <#@ template 
        debug="false" 
        hostspecific="true" 
        language="C#" #>
    <#@ assembly name="System.Core" #>
    <#@ import namespace="System.Linq" #>
    <#@ import namespace="System.IO" #>
    <#@ output extension=".cs" #>
    namespace MyNamespace
    {
       public enum SoundNames
       {
    <#
          string path = this.Host.ResolvePath("");
          Directory.SetCurrentDirectory(path);
    
          string soundFilesPath = @".\Sounds\";
          var soundNames = Directory
             .GetFiles(
                soundFilesPath, 
                "*.*", 
                SearchOption.AllDirectories)
     	 .Where(file => 
                file.ToLower().EndsWith("wav") || 
                file.ToLower().EndsWith("mp3"))
    	 .Select(soundFilePath => 
                Path.GetFileNameWithoutExtension(
                   soundFilePath))
    	 .ToList();
    
          foreach (var name in soundNames)
          {
    #>
            <#= name #>, 
    <#
          }
    #>
       }
    }
    

    See pastebin: http://pastebin.com/xXEFC1cA

    This will generate the SoundNames.cs each time you build the solution.
    Bear in mind that the folder should not be empty (it will generate an enum without members) and files names must not contain spaces (this will generate invalid code).

  2. Using templates is a very good idea. I’ve never used them myself since Unity limits you to .Net 2.0 due to it actually making use of Mono for cross-platform reasons. It can still be useful to generate the code file though and then using perhaps a post-build process to move a copy of the file to the right location in Unity (so you don’t have to do it manually). Perhaps even better the class file could be part of a DLL which is then imported into Unity (Unity allows you to use pre-compiled code) again via a post-build process. Thanks for the suggestion Roberto and for making me aware of how templates can be used!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.