About

Author: DeathByNukes

This is a part of my Audiosurf 2 scripting documentation.

This documentation is up to date as of January 2021.

Contents

Lua Environment

Audiosurf 2 scripts are written in the Lua 5.1 programming language. Because workshop scripts are downloaded from strangers, Lua is "sandboxed": dangerous features like os.execute and precompiled scripts are removed to protect players from malware. Audiosurf 2 is built using the Unity game engine and, at the time of writing, is using Unity version 2017.4.37. The Unity engine is a Mono-based environment (CLI/CLR) and so most code for it is written in the C# programming language. Audiosurf 2 uses a library based on LuaInterface to integrate Lua into this environment.

LuaInterface is an old library designed to allow Lua scripts to work with the .NET Framework (the first CLI implementation). The most popular current fork of LuaInterface is probably NLua, but the fork used by Audiosurf 2 is based on a MonoLuaInterface fork named MonoBoxedLua.

One of the results of this setup is that many internal classes and objects can be directly passed from Audiosurf 2 to Lua code as userdata without writing a wrapper. I happen to know that in the past this wasn't done because of security concerns, but that was resolved sometime around 2017.

Standard Library

Here is the current availability of the Lua 5.1 standard functions and variables:

NameStatusNotes
_G ✔️The standard contents of this table are listed here.
_VERSION ✔️"Lua 5.1"
assert ✔️
collectgarbage ✔️
dofile ⚠️Not available in mods. Path is always relative to the main script file.
error ✔️
gcinfo (shim) ✔️
getfenv ✔️
getmetatable ✔️
ipairs ✔️
load ✔️
loadfile ⚠️Not available in mods. Path is always relative to the main script file.
loadlib (shim)
loadstring ✔️
module
newproxy (undocumented) ✔️
next ✔️
pairs ✔️
pcall ✔️
print ⚠️Skin and mod environments replace it with inconsistent implementations.
rawequal ✔️
rawget ✔️
rawset ✔️
require
select ✔️
setfenv ✔️
setmetatable ✔️
tonumber ✔️
tostring ✔️
type ✔️
unpack ✔️
xpcall ✔️
coroutine ✔️
coroutine.create ✔️
coroutine.resume ✔️
coroutine.running ✔️
coroutine.status ✔️
coroutine.wrap ✔️
coroutine.yield ✔️
debug ✔️
debug.debug
debug.getfenv
debug.gethook
debug.getinfo ✔️
debug.getlocal
debug.getmetatable
debug.getregistry
debug.getupvalue
debug.setfenv
debug.sethook
debug.setlocal
debug.setmetatable
debug.setupvalue
debug.traceback ✔️
io
io.close
io.flush
io.input
io.lines
io.open
io.output
io.popen
io.read
io.stderr
io.stdin
io.stdout
io.tmpfile
io.type
io.write
file:close
file:flush
file:lines
file:read
file:seek
file:setvbuf
file:write
math ✔️
math.abs ✔️
math.acos ✔️
math.asin ✔️
math.atan ✔️
math.atan2 ✔️
math.ceil ✔️
math.cos ✔️
math.cosh ✔️
math.deg ✔️
math.exp ✔️
math.floor ✔️
math.fmod ✔️
math.frexp ✔️
math.huge ✔️
math.ldexp ✔️
math.log ✔️
math.log10 ✔️
math.max ✔️
math.min ✔️
math.mod (shim) ✔️
math.modf ✔️
math.pi ✔️
math.pow ✔️
math.rad ✔️
math.random ✔️
math.randomseed ✔️
math.sin ✔️
math.sinh ✔️
math.sqrt ✔️
math.tan ✔️
math.tanh ✔️
os ✔️
os.clock ✔️
os.date ✔️
os.difftime ✔️
os.execute
os.exit
os.getenv
os.remove
os.rename
os.setlocale
os.time ✔️
os.tmpname
package
package.cpath
package.loaded
package.loaders
package.loadlib
package.path
package.preload
package.seeall
string ✔️
string.byte ✔️
string.char ✔️
string.dump ✔️
string.find ✔️
string.format ✔️
string.gfind (shim) ✔️
string.gmatch ✔️
string.gsub ✔️
string.len ✔️
string.lower ✔️
string.match ✔️
string.rep ✔️
string.reverse ✔️
string.sub ✔️
string.upper ✔️
table ✔️
table.concat ✔️
table.foreach (shim) ✔️
table.foreachi (shim) ✔️
table.getn (shim) ✔️
table.insert ✔️
table.maxn ✔️
table.remove ✔️
table.setn (obsolete) ⚠️Exists but raises error: 'setn' is obsolete
table.sort ✔️

LuaInterface

At the moment only a few CLI types are directly exposed to scripts but, depending on what you are trying to do, they can provide critical functionality. If you happen to be familiar with C# code, it might be easiest for you to learn about it by browsing the unit tests here.

Standard Library

NameStatusNotes
luanet ⚠️The luanet table doesn't exist but its contents (below) are global variables instead.
ctype ✔️
enum ✔️
free_object
get_constructor_bysig ✔️
get_method_bysig ✔️
import_type ⚠️Exists, but no assemblies are loaded. Some whitelisted types can be imported though.
load_assembly
make_object

Type Whitelist

Audiosurf 2 has manually whitelisted some CLI types so that they can be imported with import_type(full_name):

Full NameCommon NameSemanticsLua Conversion
System.Char char struct number
System.DateTime DateTime struct userdata
System.Double double struct number
System.Int32 int struct number
System.Math Math static class n/a
System.Object object class userdata
System.Single float struct number
System.TimeSpan TimeSpan struct userdata
UnityEngine.Color32 Color32 struct userdata
UnityEngine.Color Color struct userdata
UnityEngine.Mathf Mathf static class n/a
UnityEngine.Quaternion Quaternion struct userdata
UnityEngine.Vector2 Vector2 struct userdata
UnityEngine.Vector3 Vector3 struct userdata
UnityEngine.Vector4 Vector4 struct userdata

Lua function calls don't require parentheses when there is only one argument and that argument is a table or string definition. The idiom is to use import_type like this:

Name = import_type "Namespace.Name"

There is no way to avoid LuaInterface auto conversions when working with the numeric types; even if you are passing the output of one CLI function directly to the arguments of another, it will still be converted to a Lua number (double) and back along the way. Their instance methods are effectively inaccessible. Importing those types might still be useful if you need to create CLI arrays of them or use their static members.

Working with CLI Instances and Types

The most common use case in Audiosurf 2 will be working with CLI object instances returned by functions like BuildMaterial{}, which returns an instance of the UnityEngine.Material class. CLI objects and classes can contain fields, properties, and methods. (There are other things but they aren't relevant to Audiosurf 2.) Fields are like Lua variables but they can only contain specific types. Properties look like fields and generally act like them, but reading and writing their value calls internal "get" and "set" functions rather than reading and writing to a variable. Some can also be read-only. Methods should be familiar to experienced Lua users; they are functions attached to an object and you call them in the format instance:method() as shorthand for passing the object as the function's first argument.

The Lua object produced by import_type, sometimes called a ProxyType, allows you to access the static members of a type (Type.StaticMember), construct new instances of a type (Type(arguments)), and construct CLI arrays of that type (Type[number]). Static members work like instance members, but they are global and you do not use colon (:) syntax to call static methods. This is different from the System.Type object, which is used to programmatically view the metadata and members of a type; to get that you can call example:GetType() on instances or ctype(Example) on types. (Advanced users: Accessing members via reflection has been disabled; anything in the System.Reflection namespace gets converted to a string rather than a userdata.)

Examples

Materials

local mat = BuildMaterial{
	shader = "VertexColorUnlitTintedAddSmooth",
	texture = "<ALBUMART>",
}

assert(mat.renderQueue == 3000) -- Read instance property. (Basic CLI number types are automatically converted to Lua numbers.)
mat.renderQueue = 1000 -- Set instance property. (Lua numbers are automatically converted to CLI numbers.)
local tex = mat:GetTexture("_MainTex") -- Call instance method.

assert(type(mat) == 'userdata')
assert(mat:GetType().FullName == "UnityEngine.Material")
assert(tex:GetType().FullName == "UnityEngine.Texture2D")

Color = import_type "UnityEngine.Color"

local sky_blue = Color(0, 0.5, 1, 1) -- Create a new UnityEngine.Color instance.
local black = Color.black -- Read static property.
local dark_blue = Color.Lerp(sky_blue, black, 0.5) -- Call static method. (Static methods don't use colon syntax.)

local w, h = tex.width, tex.height

-- The pixels of normal textures are marked "non-readable" but the <ALBUMART> texture settings generate a new unrestricted Texture2D every time you use them.
tex:SetPixel(0, h - 1, dark_blue) -- Turn the top left pixel dark blue.
tex:Apply()

Advanced

Color = import_type "UnityEngine.Color"
Object = import_type "System.Object"

local blue = Color.blue
local green = Color.green

-- LuaInterface doesn't currently translate CLI operator overloading into Lua operator overloading.
--local cyan = blue + green -- error("attempt to perform arithmetic on local 'blue' (a userdata value)")

local cyan = Color.op_Addition(blue, green) -- Full list of operator names is here and here.
assert(cyan.a == 2)
cyan.a = 1

assert(cyan:Equals(Color(0,1,1,1))) -- Compare the colors by value.
assert(cyan:Equals(Color(0,1,1))) -- Multiple constructors.
assert(tostring(cyan) == cyan:ToString()) -- LuaInterface implements the tostring metatable field.
assert(tostring(cyan) == "RGBA(0.000, 1.000, 1.000, 1.000)")

-- Create CLI array of colors.
local colors = Color[3] -- Create CLI array of colors. Equivalent to System.Array.CreateInstance(ctype(Color), 3).
assert(colors.Length == 3)
--assert(#colors == 3) -- error("attempt to get length of local 'colors' (a userdata value)")
colors[0] = blue -- CLI arrays start with zero.
colors[1] = black -- If you tried to assign some other type like "foo" or {0,0,0} it would cause an error.
colors[2] = dark_blue

-- LuaInterface is not able to translate CLI value types (aka struct) to Lua in a way that preserves by-value semantics.
-- Each time a class or struct is passed from the CLI to Lua it creates a new Lua userdata object, which is a reference type like tables.
-- If the object is a class or was already boxed before being passed to Lua, it is possible for multiple Lua objects to refer to the same CLI object.
local a, b, c, d, e, array
a = Color.white -- New Lua object and new CLI object.
b = Color.white -- New Lua object and new CLI object, containing the same value.
c = a -- Reference to the same Lua object.

array = Color[1]
array[0] = a -- A specific type CLI variable stores structs by value.
d = array[0] -- New Lua object and new CLI object, containing the same value.

array = Object[1]
array[0] = a -- An Object type CLI variable stores structs by reference. LuaInterface userdata are Object type internally, so a new object will not be created here.
e = array[0] -- New Lua object containing a reference to the same CLI object.

assert(a.r == 1 and b.r == 1 and c.r == 1 and d.r == 1 and e.r == 1)
a.r = 0
assert(a.r == 0 and b.r == 1 and c.r == 0 and d.r == 1 and e.r == 0)

-- Currently equality operators are always reference comparison, but operator overloading may be implemented in the future so don't do this.
assert(a ~= b)

-- This is the canonical way to compare Lua objects by reference but it insufficient for CLI types because
-- the same CLI object might be referenced by multiple Lua userdata objects.
assert(not rawequal(a, b))
assert(rawequal(a, c))
assert(not rawequal(a, d))
assert(not rawequal(a, e))

-- The correct way to compare CLI types by reference.
assert(not Object.ReferenceEquals(a, b))
assert(Object.ReferenceEquals(a, c))
assert(not Object.ReferenceEquals(a, d))
assert(Object.ReferenceEquals(a, e))

-- Accessing the array twice creates different objects.
assert(not Object.ReferenceEquals(colors[0], colors[0]))

Header

These scripts run first inside skin and mod Lua environments:

Sandbox

This chunk can be disabled by the command line flag +disablemodsecuritysandbox. That flag seems to have been neglected when the security system was upgraded, as this is the only part of the game that checks it.


assert(nil == package)
assert(nil == io)
assert(nil == require)
assert(nil == module)

assert(nil == os.execute)
assert(nil == os.exit)
assert(nil == os.getenv)
assert(nil == os.remove)
assert(nil == os.rename)
assert(nil == os.setlocale)
assert(nil == os.tmpname)

assert(nil == luanet)
assert(nil == load_assembly)

--dofile = nil
--loadfile = nil

Library




-- each DoString is a different Lua scope, no need for a giant do...end
local auto, assign

function auto(tab, key)
    return setmetatable({}, {
            __index = auto,
            __newindex = assign,
            parent = tab,
            key = key
    })
end

local meta = {__index = auto}

-- The if statement below prevents the table from being
-- created if the value assigned is nil. This is, I think,
-- technically correct but it might be desirable to use
-- assignment to nil to force a table into existence.

function assign(tab, key, val)
    -- if val ~= nil then
    local oldmt = getmetatable(tab)
    oldmt.parent[oldmt.key] = tab
    setmetatable(tab, meta)
    tab[key] = val
    -- end
end

function AutomagicTable()
    return setmetatable({}, meta)
end

function fif(test, if_true, if_false)
    if test then return if_true else return if_false end
end

function deepcopy(orig)
    local orig_type = type(orig)
    local copy
    if orig_type == 'table' then
        copy = {}
        for orig_key, orig_value in next, orig, nil do
            copy[deepcopy(orig_key)] = deepcopy(orig_value)
        end
        setmetatable(copy, deepcopy(getmetatable(orig)))
    else -- number, string, boolean, etc
        copy = orig
    end
    return copy
end


-- start coroutine helpers
-- This table is indexed by coroutine and simply contains the time at which the coroutine
-- should be woken up.
local WAITING_ON_TIME = WAITING_ON_TIME or {}

-- Keep track of how long the game has been running.
local CURRENT_TIME = CURRENT_TIME or 0

function waitSeconds(seconds)
    -- Grab a reference to the current running coroutine.
    local co = coroutine.running()

    -- If co is nil, that means we're on the main process, which isn't a coroutine and can't yield
    assert(co ~= nil, 'The main thread cannot wait!')

    -- Store the coroutine and its wakeup time in the WAITING_ON_TIME table
    local wakeupTime = CURRENT_TIME + seconds
    WAITING_ON_TIME[co] = wakeupTime

    -- And suspend the process
    return coroutine.yield(co)
end

function wakeUpWaitingThreads(deltaTime)
    -- This function should be called once per game logic update with the amount of time
    -- that has passed since it was last called
    CURRENT_TIME = CURRENT_TIME + deltaTime

    -- First, grab a list of the threads that need to be woken up. They'll need to be removed
    -- from the WAITING_ON_TIME table which we don't want to try and do while we're iterating
    -- through that table, hence the list.
    local threadsToWake = {}
    for co, wakeupTime in pairs(WAITING_ON_TIME) do
        if wakeupTime < CURRENT_TIME then
            table.insert(threadsToWake, co)
        end
    end

    -- Now wake them all up.
    for _, co in ipairs(threadsToWake) do
        WAITING_ON_TIME[co] = nil -- Setting a field to nil removes it from the table
        coroutine.resume(co)
    end
end

function runProcess(func)
    -- This function is just a quick wrapper to start a coroutine.
    local co = coroutine.create(func)
    return coroutine.resume(co)
end

local WAITING_ON_SIGNAL = WAITING_ON_SIGNAL or {}

function waitSignal(signalName)
    -- Same check as in waitSeconds; the main thread cannot wait
    local co = coroutine.running()
    assert(co ~= nil, 'The main thread cannot wait!')

    if WAITING_ON_SIGNAL[signalStr] == nil then
        -- If there wasn't already a list for this signal, start a new one.
        WAITING_ON_SIGNAL[signalName] = { co }
    else
        table.insert(WAITING_ON_SIGNAL[signalName], co)
    end

    return coroutine.yield()
end

function signal(signalName)
    local threads = WAITING_ON_SIGNAL[signalName]
    if threads == nil then return end

    WAITING_ON_SIGNAL[signalName] = nil
    for _, co in ipairs(threads) do
        coroutine.resume(co)
    end
end
--end coroutine helpers

AutomagicTable matches the code found here: http://lua-users.org/wiki/AutomagicTables

Random Seed

A Lua script line is generated that calls math.randomseed(n) where n is the total length of the song, in seconds, with no decimal. (This is appropriate because math.randomseed discards the fraction anyways.)