Skip to content
False.Genesis edited this page Jul 4, 2024 · 3 revisions

Preparing for modding:

In your usersettings.xml, make sure <DeveloperMode on="1" /> is set. This setting enables debug hotkeys and reports errors properly instead of ignoring them silently. Silent, undetected errors is the worst part of scripting because things will unexplainably not work, so you'll want all the help and strictness and warnings you can get.

Aquaria uses Lua 5.1 as its scripting language. For editing scripts you want a text editor with syntax highlighting features. Which one to pick depends on your system.

To get started with scripting, you need some basic programming knowledge or enough motivation to pick it up along the way.

If you're not familiar with Lua, read https://www.lua.org/manual/5.1/manual.html, at least chapters 1, 2 and 5. Lua is one of the simplest scripting languages out there and is quite easy to pick up.

If you're a bloody beginner:

Look for tutorials. Lua 5.1 to 5.4 all equally apply since the core language has stayed almost the same over the years. This is not the place to teach you basics of programming.

If you've used other scripting languages before, here's a few gotchas compared to most other languages.

  • Use == to check for equality, ~= to check for inequality (most other languages use !=, which does not exist in Lua)
  • The "table" is the only data structure that exists. They are arrays and hash maps in one.
  • Array indices start at 1 (not 0). This means an array A with N elements goes from A[1] til A[N].
  • A.key = 5 is syntax sugar for A["key"] = 5.
  • You can use any value except nil as a table key.
  • A[0], A[-42], etc. can be used normally, but will not be picked up by the # operator or ipairs(), because indices <= 0 end up in the hash part, not the array part of the table.
  • Table access:
    • Reading an index/key that doesn't exist returns nil
    • Writing an index/key that doesn't exist does an insert
      • t[#t+1] is one way to append a value to an array.
      • Alternatively, use table.insert().
      • Write a nil value to erase a key
  • Array length (# operator) is defined as an integer i that satisfies i >= 0 and A[i] ~= nil and A[i+1] == nil, ie. the index of some non-nil value followed by a nil. If multiple such i exist you may get one or the other (Lua does a binary search internally to find a suitable index i)
  • The ONLY values that are false-y in an if statement are false and nil. Anything else counts as true. Yes, 0, empty string, empty table all count as true-y. This is different in e.g. Python.
  • The global namespace can be accessed as the table _G (yes this is just a global symbol. Yes you can iterate over all globals and get a list of functions and constants that way.)
  • Any variable that is not marked 'local' is global. Variables not declared are assumed to be globals. That means if you want to call a function but typo the name, Lua will load the script without warnings. When the script is then run, Lua tries to fetch the typo'd symbol from _G, that fails so nil is returned; then it happily proceeds to call the result as a function, untilmately failing with "attempt to call a nil value".
  • Lua 5.1 has no integers. Integers just happen to be floats where the mantissa is zero. Avoid using floats as table keys. You can do it, but if your table key is the result of a math expression, float inaccuracies may cause subsequent lookups to fail.
  • Don't be confused by "every int is a float". If you use "ints" and do "int" math, they will stay "ints". For divisions, use math.floor() to make sure the division result is an "int".
  • There are no bit operations. You need to shoehorn something out of multiplications, divisions, and math.floor(). Luckily for Aquaria modding you will most likely never need bit operations. (Lua 5.2 has bit ops but we're stuck on 5.1, yay)
  • Functions can return multiple values:
    • local x, y, z = entity_getPosition(e) sets x, y to numbers and z to nil, because entity_getPosition() returns (only) 2 values.
    • If the last parameter to a function call is the result of another function call, all return values are forwarded as call parameters:
      entity_setPosition(e, node_getPosition(node)) (This forwards x, y as node_getPosition() returns 2 values)
  • Lua has no idea how many parameters a function is going to take. Missing function parameters are passed as nil, extraneous ones are ignored. This can lead to mistakes like this:
    entity_scale(me, 2) causes an entity to visually disappear.
    Why? Because entity_scale(entity, scaleX, scaleY, ...) takes more than 2 parameters (most of which are optional); scaleY is passed as nil, which is silently converted to 0 on the C++ side. And a scale factor of 0 on one axis makes an object appear infinitely thin, so it's not drawn.

Aquaria-specific things:

Removed/replaced functionality

  • Don't use print(). You won't see the output. Use debugLog() to print text into the game's debug log. On OSE versions, you can use errorLog() to pop open a messagebox.
  • The io and os sections of the standard library do not exist.
  • debug and coroutine don't exist in pre-OSE versions.

Script organization

A script in Aquaria is per-entity-type or per-node-type, means all entities of the same type share the same script.

  • Entity scripts have no prefix. An entity named "tromulo" will load "scripts/tromulo.lua"
  • A script is only loaded once when needed and then stays in memory until the last instance dies.
  • Nodes are prefixed with "node_", ie. a "zoomx" node will load "scripts/node_zoomx.lua"
  • Entity scripts are loaded from the current mod if the corresponding file exists. If it doesn't, it checks the base game scripts/ directory and loads it from there if possible.
  • Node scripts are always loaded from the current mod. There is no fallback to the base game. If you need a node from the base game, copy the script file to your mod.
  • Other types are prefixed accordingly. (map_, shot_, etc).
  • Some scripts are special (TODO)

Aquaria adds a ton of functions to the global namespace. There is currently no documentation for these; the best source of information is ScriptInterface.cpp

Coding rules

There are some peculiarities in how Aquaria handles scripting you need to be aware of to avoid errors. Old versions (<= 1.1.1, e.g. from Steam, GOG, etc) use an entirely separate Lua state for each individual node and entity. This means scripts can't interfere with one another, but aside from the entity_msg() function there is no way to communicate across scripts.

Newer versions use a single, global Lua state for ALL the scripts. This is much more efficient and allows sharing code and data, however care must be taken that scripts don't stomp one another.

The long and detailed explanation and coding guide can be found in the code: https://github.com/AquariaOSE/Aquaria/blob/master/Aquaria/ScriptInterface.cpp#L106

In short:

  • Don't use globals except for constant values (and make those UPPERCASE)
  • DO use globals for interface functions (the game will remove a select set of globals after loading a script and store those elsewhere)
  • For instance locals (eg. a single scripted fish's variables), use the magic global v to store variables. The engine manages v for you, don't overwrite it, just use it.
  • It is good practice (but not required) to initialize instance locals at the top of the script. The instance's v will then have these initial values pre-loaded.
    • However, if you do this, make sure to not initialize tables at the top, ie. v.t = {}. This must be done in init()

Datatypes

Nodes, entities, shots, and any other game object are implemented as light userdata.

  • Lua's built-in type() function will return "userdata", not knowing what the underlying object type is
  • You can use isEntity(), isNode(), etc to figure out the object type
  • Objects does not survive a map load. Don't try to store entity pointers across maps. It won't work, all you'll get is a dangling pointer.
  • If you happen to pass a node into a function that expects an entity, that will not work and you'll get a warning.

For compatibility reasons, non-existing objects are 0 (the integer zero), not nil. For example, consider this line:

local node = getNode("hello")

If a "hello" node exists on the map, it will be returned as a light userdata value. If no such node exists you'd expect a return value of nil or false, but it's actually zero. This is a little problematic because 0 is a true-y value in Lua, so you need to check for it explicitly:

WRONG:

if node then
	entity_setPosition(getNaija(), node_getPosition(node))
end

CORRECT:

if node ~= 0 then
	entity_setPosition(getNaija(), node_getPosition(node))
end

Why? This is a leftover from back when pointers were passed as integers. ALL the scripts did this (checking against integer 0), and because this was never changed this is now how it is. Sad Pikachu face.

Misc

While Lua uses 1-based indexing, exported game functions do not. To iterate an array in Lua, you'd do:

for i = 1, #t do
	do_a_thing(t[i])
end

To iterate anything that calls a game function, make it 0-indexed:

for i = 0, N-1 do
	local x, y = node_getPathPosition(node, i)
end

Choosing your target

Also see mod compatibility.

Supporting only OSE versions

(This is the approach used in Meatymod.)

This is the simplest and recommended approach. You will have proper error reporting and warnings for the most common mistakes, plus all the new features and goodies. The downside is that people need an update to play your mod.

Consider disabling deprecated functions via

<Compatibility script="no-deprecated" />

so you don't accidentally use them.

If any of your scripts use dofile() to reference a .lua file, you can simply do this:

dofile("scripts/filename.lua")

and the file will be loaded from your mod.

Using OSE to develop but keep backwards compatibility

(This is the approach used in Labyrinth mod.)

You can detect whether you run in an OSE version by checking AQUARIA_VERSION. If it's not nil you're running under the new scripting interface.

Again, read the big comment block here: https://github.com/AquariaOSE/Aquaria/blob/master/Aquaria/ScriptInterface.cpp#L106

In short, add these two lines at the top of every script:

if not AQUARIA_VERSION then dofile("scripts/entities/entityinclude.lua") end
if not v then v = {} end

The other gotcha is if any of your scripts use dofile() to reference a .lua file in your mod, you need to do this:

dofile(appendUserDataPath("_mods/YOUR_MOD_NAME/scripts/filename.lua"))

(Yes, you need to include the directory name of your mod every single time.)

You can also not use any of the new script functions because they don't exist for people running an old version.

When done correctly, your mod will run in any version. There are some issues in older versions that can cause crashes, but the cause is unknown. If in doubt, urge players to upgrade to a new game version, those are more stable.

Supporting old versions, using the compatibility layer

(This is the approach used for eg. Sacrifice Mod, since it was developed before the new script interface was a thing)

In your mod xml, add this line:

<Compatibility script="legacy-strict" />

This applies some magic to the game's Lua state that it can run scripts that are not aware of the shared Lua state, v table and any of the new script rules.

It doesn't work 100% exactly the same as an old version would but the "emulation" is good enough to make old mods run. Make sure to test it in old and new versions though to make sure it works properly. Don't ever name a variable v.

It is not recommended to develop scripts in compatibility mode because you will get no warnings for even the most obvious script errors. Also note that you can't use any new functions in this mode.