This is a part of my Audiosurf 2 scripting documentation.
This documentation is up to date as of January 2021.
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.
Here is the current availability of the Lua 5.1 standard functions and variables:
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.
Name | Status | Notes |
---|---|---|
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 | ❌ |
Audiosurf 2 has manually whitelisted some CLI types so that they can be imported with import_type(full_name)
:
Full Name | Common Name | Semantics | Lua 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.
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.)
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()
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]))
These scripts run first inside skin and mod Lua environments:
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
-- 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
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.)