Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4511658
matrix4.h: Extract & expose determinant
appgurueu May 9, 2025
1bbf0a9
WIP matrix & rotation lua APIs
appgurueu May 10, 2025
5cd05c5
Polish
appgurueu May 29, 2025
f5f5e0b
improve some things
appgurueu May 30, 2025
520562e
fix & bustitute
appgurueu May 30, 2025
2b53ad8
more stuff
appgurueu May 31, 2025
7008ad7
update .luacheckrc
appgurueu May 31, 2025
9f3a7d2
.
appgurueu May 31, 2025
1cee968
or not
appgurueu May 31, 2025
a91aab0
envs
appgurueu May 31, 2025
c1f81f4
Document that matrix composition is RTL
appgurueu Jun 2, 2025
fc78638
Improve `:compose` documentation
appgurueu Jun 2, 2025
a80ffcb
Improve docs
appgurueu Jun 2, 2025
7aff109
Rename rotation euler angle functions to euler_xyz
appgurueu Jul 4, 2025
6f10600
- Use typical conventions (column vectors)
appgurueu Jan 21, 2026
dde62da
Add Matrix4.trs
appgurueu Jan 21, 2026
ed83284
Discourage use of vector rotation methods
appgurueu Jan 21, 2026
b40dd9e
Abbreviations
appgurueu Jan 21, 2026
69ea311
Add vector.unpack
appgurueu Jan 21, 2026
4f74d23
Add Rotation.euler_zxy_rh constructor
appgurueu Jan 21, 2026
fffe3b3
fixup abbr
appgurueu Jan 21, 2026
305d6fa
test and fix
appgurueu Jan 21, 2026
6ede96a
Add euler_zxy conversions
appgurueu Jan 21, 2026
b36db6f
remove redundant-ish test
appgurueu Jan 21, 2026
545bfd2
.luacheckrc
appgurueu Jan 21, 2026
3db0766
Update docs
appgurueu Feb 4, 2026
8110803
Euler
appgurueu Feb 4, 2026
f42c657
...
appgurueu Feb 4, 2026
caba0fa
minor stuff
appgurueu Feb 4, 2026
63af28a
scope luacheck global decls to unittests
appgurueu Feb 4, 2026
2fad015
.
appgurueu Feb 4, 2026
808793e
.
appgurueu Feb 4, 2026
4828bd5
move matrix & quaternion operator << to irrlicht_changes/printing.h
appgurueu Feb 5, 2026
e2378fb
precision
appgurueu Feb 5, 2026
6cc50b2
details
appgurueu Feb 5, 2026
bbc1da8
rawr
appgurueu Feb 7, 2026
2db2bc4
Add `Rotation.mapsto`
appgurueu Feb 7, 2026
30642e7
Fix remnant from transposition
appgurueu Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions builtin/common/tests/vector_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ describe("vector", function()
assert.is_true(vector.check(vector.copy(v)))
end)

it("unpack()", function()
local x, y, z = vector.new(1, 2, 3):unpack()
assert.equal(1, x)
assert.equal(2, y)
assert.equal(3, z)
end)

it("indexes", function()
local some_vector = vector.new(24, 42, 13)
assert.equal(24, some_vector[1])
Expand Down
4 changes: 4 additions & 0 deletions builtin/common/vector.lua
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ function vector.copy(v)
return fast_new(v.x, v.y, v.z)
end

function vector.unpack(v)
return v.x, v.y, v.z
end

function vector.from_string(s, init)
local x, y, z, np = string.match(s, "^%s*%(%s*([^%s,]+)%s*[,%s]%s*([^%s,]+)%s*[,%s]" ..
"%s*([^%s,]+)%s*[,%s]?%s*%)()", init)
Expand Down
226 changes: 221 additions & 5 deletions doc/lua_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3051,7 +3051,7 @@ Elements
* `textures`: The mesh textures to use according to the mesh materials.
Texture names must be separated by commas.
* `rotation` (Optional): Initial rotation of the camera, format `x,y`.
The axes are euler angles in degrees.
The axes are Euler angles in degrees.
* `continuous` (Optional): Whether the rotation is continuous. Default `false`.
* `mouse control` (Optional): Whether the model can be controlled with the mouse. Default `true`.
* `frame loop range` (Optional): Range of the animation frames.
Expand Down Expand Up @@ -3942,7 +3942,7 @@ It means that when you're pointing in +Z direction in-game ("forward"), +X is to

Consistently, rotation is [**left-handed**](https://en.wikipedia.org/w/index.php?title=Right-hand_rule) as well.
Luanti uses [Tait-Bryan angles](https://en.wikipedia.org/wiki/Euler_angles#Tait%E2%80%93Bryan_angles) for rotations,
often referred to simply as "euler angles" (even though they are not "proper" euler angles).
often referred to simply as "Euler angles" (even though they are not "proper" Euler angles).
The rotation order is extrinsic X-Y-Z:
First rotation around the (unrotated) X-axis is applied,
then rotation around the (unrotated) Y-axis follows,
Expand Down Expand Up @@ -4036,8 +4036,8 @@ For the following functions (and subchapters),
`s` is a scalar (a number),
vectors are written like this: `(x, y, z)`:

* `vector.new([a[, b, c]])`:
* Returns a new vector `(a, b, c)`.
* `vector.new(x, y, z)`:
* Returns a new vector `(x, y, z)`.
* Deprecated: `vector.new()` does the same as `vector.zero()` and
`vector.new(v)` does the same as `vector.copy(v)`
* `vector.zero()`:
Expand All @@ -4046,6 +4046,8 @@ vectors are written like this: `(x, y, z)`:
* Returns a new vector of length 1, pointing into a direction chosen uniformly at random.
* `vector.copy(v)`:
* Returns a copy of the vector `v`.
* `vector.unpack(v)`:
* Returns the `x`, `y` and `z` components of the vector individually
* `vector.from_string(s[, init])`:
* Returns `v, np`, where `v` is a vector read from the given string `s` and
`np` is the next position in the string after the vector.
Expand Down Expand Up @@ -4095,7 +4097,9 @@ vectors are written like this: `(x, y, z)`:
* `vector.dot(v1, v2)`:
* Returns the dot product of `v1` and `v2`.
* `vector.cross(v1, v2)`:
* Returns the cross product of `v1` and `v2`.
* Returns the *right-handed* cross product of `v1` and `v2`.
* To get the left-handed cross product
(e.g. for use with rotations), swap `v1` and `v2`.
Comment on lines 4099 to +4102
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cross((1,0,0), (0,1,0)) is (0,0,1).
In a right-handed coord system this is right-handed.
But luanti worlds use a left-handed coord system, so it's left-handed.

Doc needs to be clear. Just saying "right-handed" is ambiguous.

* `vector.offset(v, x, y, z)`:
* Returns the sum of the vectors `v` and `(x, y, z)`.
* `vector.check(v)`:
Expand Down Expand Up @@ -4156,6 +4160,10 @@ For the following functions `a` is an angle in radians and `r` is a rotation
vector (`{x = <pitch>, y = <yaw>, z = <roll>}`) where pitch, yaw and roll are
angles in radians.

Use of these functions is **discouraged** because they use
deprecated rotation conventions.
Prefer to use `Rotation` objects when possible.

* `vector.rotate(v, r)`:
* Applies the rotation `r` to `v` and returns the result.
* Uses (extrinsic) Z-X-Y rotation order and is right-handed, consistent with `ObjectRef:set_rotation`.
Expand Down Expand Up @@ -4183,7 +4191,215 @@ For example:
* `core.hash_node_position` (Only works on node positions.)
* `core.dir_to_wallmounted` (Involves wallmounted param2 values.)

Rotations
=========

Luanti provides a proper helper class for working with 3d rotations.
Using vectors of Euler angles instead is discouraged as it is error-prone.
This class was added in Luanti 5.17.0.

The precision of the implementation may change (improve) in the future.
Rotations currently use 32-bit floats (*less* precision than the Lua number type).

Rotations use **left-handed** conventions.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here.


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
In the current implementation, Rotations are just an abstraction over quaternions.

Would be helpful info (and grep endpoint) imo for users who want to use quaternions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think they would hit the "quaternion" constructor anyways. But sure, I can add this so nobody goes "where are my quaternions!?" 😅

Constructors
------------

* `Rotation.identity()`: Constructs a no-op rotation.
* `Rotation.quaternion(x, y, z, w)`:
Constructs a rotation from a quaternion (which need not be normalized).
* `Rotation.axis_angle(axis, angle)`:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(

Also here, sounds like it's measuring the axis of an angle (yes this makes no sense, but that's how I read x), the thinking comes afterwards).
from_axis_angle would be more clear.

Similar for other ctors.

Well, I guess one can get accustomed to this kind of naming.

)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something I considered, but it makes things a little more verbose and I don't like that. I'm open to changing it though. 🤷

Constructs a rotation around the given axis by the given angle.
* `axis` is a nonzero vector, which need not be normalized
* `angle` is in radians
* Example: `Rotation.axis_angle(vector.new(1, 0, 1), math.pi/2)`
is a half-turn around the bisector between the X and Z axes.
* There are shorthands for rotations around the cardinal axes:
* `Rotation.x(pitch)`
* `Rotation.y(yaw)`
* `Rotation.z(roll)`
* `Rotation.euler_xyz(pitch, yaw, roll)`
* All angles in radians.
* Uses X-Y-Z rotation order, equivalent to
`Rotation.compose(Rotation.z(roll), Rotation.y(yaw), Rotation.x(pitch))`.
* Consistent with the Euler angles that can be used for bones or attachments.
* `Rotation.euler_zxy(pitch, yaw, roll)`
* Same as `euler_xyz`, but uses Z-X-Y rotation order.
* This is consistent with the Euler angles that can be used for entities.
You can do `Rotation.euler_zxy((-rotation):unpack())`
to convert an entity rotation vector (note the handedness conversion).
* `Rotation.mapsto(dir_from, dir_to)`:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound like a function that returns a bool.
Would prefer mappingto or similar.

Also, why no snake case? maps_to

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think of "mapsto" as a single symbol, but you're right that our naming convention requires an underscore here.

Construct a rotation that maps `dir_from` to `dir_to`.
* `dir_from` and `dir_to` are nonzero direction vectors.
* The given rotation only rotates in the plane spanned by the two vectors. It is thus uniquely defined.
* `Rotation.compose(...)`: Returns the composition of the given rotations.
* `Rotation.compose()` is an alias for `Rotation.identity()`.
* `Rotation.compose(rot)` copies the rotation.
* `Rotation.compose(...)` for at least two rotations composes the given rotations
in right-to-left order. This means that `Rotation.compose(second, first):apply(v)`
is equivalent to `second:apply(first:apply(v))`:
The composed rotation first applies `first`, then `second` to a vector.

Conversions
-----------

Corresponding to the constructors, rotations can be converted
to different representations.
Note that this conversion is not guaranteed to produce the same values you put in.
It is only guaranteed that the values produce an approximately equivalent rotation
when passed to the corresponding constructor.

* `x, y, z, w = rot:to_quaternion()`
* Returns the normalized quaternion representation.
* `axis, angle = rot:to_axis_angle()`
* `axis` is a normalized vector that can point in any direction.
* `angle` is the rotation about this axis as an angle in radians.
* `pitch, yaw, roll = rot:to_euler_xyz()`
* Angles are all in radians.
* `pitch`, `yaw`, `roll`: Rotation around the X-, Y-, and Z-axis respectively.
* Inverse of `Rotation.euler_xyz`.
* `pitch, yaw, roll = rot:to_euler_zxy()`
* Same as `to_euler_xyz`, except uses Z-X-Y rotation order.
* To obtain a rotation for an entity, you can do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* To obtain a rotation for an entity, you can do
* To obtain a dings-handed rotation in W-W-W order used for entities, you can do

(Where dings and W-W-W are templates.)

`-vector.new(rot:to_euler_xyz())`.

Rotations can also be converted to matrices using `Matrix4.rotation(rot)`.

Methods
-------

* `rot:compose(...)`: Shorthand for `Rotation.compose(rot, ...)`.
* `rot:apply(vec)`: Returns the result of applying the rotation to the given vector.
* `rot:invert()`: Returns the inverse rotation.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Could be "inverse" for noun naming.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think it reads better if we don't stick too rigidly to noun naming. I don't want "compose" to be "composition" etc.

For another example, if we stuck strictly to output-based noun naming, it'd have to be vector.sum(v, w), vector.product(v, w), vector.difference(v, w) etc. which would be a bit silly x)

* `from:slerp(to, time)`: Interpolate from one rotation to another.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"In-place" or "returns interpolated"?

Also, if latter, can we have Rotation.slerp(from, to, time)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns interpolated. We can have that. In general, I wonder whether all methods should be accessible as Rotation.method just as well. Probably good for consistency with how Lua-based classes tend to be implemented (e.g. vector).

* `time = 0` is all `from`, `time = 1` is all `to`.
* `rot:angle_to(other)`: Returns the absolute angle between two quaternions.
* Useful to measure similarity.

Rotations implement `__tostring`. The format is only intended for human-readability,
not serialization, and may thus change in the future.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, maybe we should also implement concat, also for vectors. (I didn't back then for vectors and I'm not sure how to feel about it. But "bla"..v not working is kinda annoying in practice.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel it's better not to have it, because I think that users usually ought to be explicit in which formatting they want, and this helps enforce that.

Often, you probably want to round many digits away (how many?). Other times, you might want the data to be preserved exactly. No default fits all well here, it's just a foot gun. Users should be forced to make an explicit decision.

See also #16943 for related discussion.



Matrices
========

Luanti uses 4x4 matrices to represent linear transformations of 3D vectors.
For this, 3D vectors are embedded into 4D space.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

into 3D projective space using homogeneous coordinates.

This class was added in Luanti 5.17.0.

The matrices use column-major conventions.
Vectors are treated as column vectors.
This means the first column is the image of the vector (1, 0, 0, 0),
the second column is the image of (0, 1, 0, 0), and so on.
Thus the translation is in the last column.

You must account for loss of precision in matrix calculations.
Matrices currently use 32-bit floats (*less* precision than the Lua number type).
However, your code should not expect imprecisions either.
Matrices may carry out computations more precisely in the future.
Comment on lines +4299 to +4300
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upper sentence is kinda confusing, imo.
Maybe "imprecision is not guaranteed" or so.
The formulation for Rotations was more clear imo.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will clarify


You should not rely on the internal representation or type of matrices.
You should only interact with matrices through the interface documented below.
This allows us to replace the implementation in the future.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/should/must/

Would also be good to explicitly mention the userdata type here.

Also, Rotations should have the same paragraph.

Copy link
Contributor Author

@appgurueu appgurueu Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was sort of planning to explicitly not mention the userdata type. Why should the user care after I've just told them explicitly not to? x)

Also, Rotations should have the same paragraph.

Hmm. At some point some of this should probably be a general note on "classes" to not be repeated on every new "class" we introduce.


Matrices are very suitable for constructing, composing and applying
linear transformations; they are not useful for exact storage of
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linear 4D transformations.
But in 3D we get projective transformations, not just linear.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed

TRS transformations if the properties need to be handled separately:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please spell out TRS at least once.

Decomposition into rotation and scale will be expensive and inexact.
You should instead store the translation, rotation and scale.

Constructors
------------

* `Matrix4.new(r1c1, r1c2, ..., r4c4)`:
Constructs a matrix from the given 16 numbers in row-major order.
* `Matrix4.identity()`: Constructs an identity matrix.
* `Matrix4.full(number)`: Constructs a matrix where all entries are the given number.
* `Matrix4.translation(vec)`: Constructs a matrix that translates vectors by the given vector.
* `Matrix4.rotation(rot)`: Constructs a matrix that applies the given `Rotation` to vectors.
* `Matrix4.scale(s)`: Constructs a matrix that applies the given
component-wise scaling factors to vectors.
`s` can be a vector or a number.
* `Matrix4.trs([t], [r], [s])`: Shorthand for
`Matrix4.compose(Matrix4.translation(t), Matrix4.rotation(r), Matrix4.scale(s))`.
All parameters are optional and default to identity transforms.
* `Matrix4.reflection(normal)`: Constructs a matrix that reflects vectors
at the plane with the given plane normal vector (which need not be normalized).
* `Matrix4.compose(...)`: Variadic composition of the given matrices.
As is common in mathematics, matrices are applied in left-to-right order.
* `Matrix4.compose(...)`: Returns the composition of the given matrices.
* `Matrix4.compose()` is an alias for `Matrix4.identity()`.
* `Matrix4.compose(mat)` copies the matrix.
* `Matrix4.compose(...)` for at least two rotations composes the given matrices
in right-to-left order. This means that `Matrix4.compose(second, first):apply(v)`
is equivalent to `second:apply(first:apply(v))`:
The composed matrix first applies `first`, then `second` to a vector.

Container utilities:

For all of the below methods, `row` and `col`umn indices range from `1` to `4`.

* `mat:get(row, col)`: Returns the number in the given row and column.
* `mat:set(row, col, number)`: Set the entry in the given row and column to the given number.
* `x, y, z, w = mat:get_row(row)`: Get the 4 numbers in the given row.
* `mat:set_row(row, x, y, z, w)`: Set the 4 numbers in the given row.
* `x, y, z, w = mat:get_col(col)`: Get the 4 numbers in the given column.
* `mat:set_col(col, x, y, z, w)`: Set the 4 numbers in the given column.
* `mat:copy()`: Returns a new matrix containing the same numbers.
* `r1c1, r1c2, ..., r4c4 = mat:unpack()`:
Get the 16 numbers in the matrix in row-major order.
`Matrix4.new(mat:unpack())` is thus equivalent to `mat:copy()`.

Linear algebra:

* Vector transformations:
* `x, y, z, w = mat:transform_4d(x, y, z, w)`: Apply the matrix to a 4d vector.
* `mat:transform_pos(pos)`:
* Apply the matrix to a vector representing a position.
* Applies the transformation as if w = 1 and discards the resulting w component.
Comment on lines +4358 to +4360
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So ... does this effectively do M * T(v.append(1)) where T means transposing?

Copy link
Contributor Author

@appgurueu appgurueu Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you take v.append(1) to be the row vector (x, y, z, 1), and then discard the w component: Yes.

* `mat:transform_dir(dir)`:
* Apply the matrix to a vector representing a direction.
* Ignores the fourth row and column; does not apply the translation (w = 0).
* `mat:compose(...)`: Shorthand for `Matrix4.compose(mat, ...)`.
* `mat:determinant()`: Returns the determinant.
* `mat:invert()`: Returns a newly created inverse, or `nil` if the matrix is (close to being) singular.
* `mat:transpose()`: Returns a transposed copy of the matrix.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(

Would like to also have the adjucate (adj) (essentially a cheaper but scaled inverse). (And combination of adj and transposed.)
(Probably will only get relevant once we have 4d vectors though.)
(Could also have even more ctors then, i.e. for projection matrices.)

)

* `mat:equals(other, [tolerance = 0])`:
Returns whether all components differ in absolute value at most by the given tolerance.
* `m1 == m2`: Returns whether `m1` and `m2` are identical (`tolerance = 0`).
* `mat:is_affine_transform([tolerance = 0])`:
Whether the matrix is an affine transformation in 3d space,
meaning it is a 3d linear transformation plus a translation.
(This is the case if the last row is approximately 0, 0, 0, 1.)

For working with affine transforms, the following methods are available:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if not, methods will still exist, right? UB? Or unspecified result? (I.e. may it crash?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, will still exist. basically UB. in practice likely bunch of NaNs, maybe SIGFPE?


* `mat:get_translation()`: Returns the translation as a vector.
* `mat:set_translation(vec)`: Sets (overwrites) the translation in the last row.

For TRS transforms specifically,
let `mat = Matrix4.compose(Matrix4.translation(t), Matrix4.rotation(r), Matrix4.scale(s))`.
Then we can decompose `mat` further. Note that `mat` must not shear or reflect.

* `rotation, scale = mat:get_rs()`:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reading mat:get_rs() somewhere, I wouldn't know what it means. Is it that common that it needs such a short name?
get_rot_scale?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TRS is a common thing, but I'm not sure if RS is clear to people

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will give this a longer name.

Extracts a `Rotation` equivalent to `r`,
along with the corresponding component-wise scaling factors as a vector.

Operators
---------

Similar to vectors, matrices define some element-wise arithmetic operators:

* `m1 + m2`: Returns the sum of both matrices.
* `m1 - m2`: Shorthand for `m1 + (-m2)`.
* `-m`: Returns the additive inverse.
* `m * s` or `s * m`: Returns the matrix `m` scaled by the scalar `s`.
* Note: *All* entries are scaled, including the last column:
The matrix may not be an affine transform afterwards.

Matrices also define a `__tostring` metamethod.
This is only intended for human readability and not for serialization.


Helper functions
Expand Down
11 changes: 11 additions & 0 deletions games/devtest/.luacheckrc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ read_globals = {
"check",
"PseudoRandom",
"PcgRandom",
"Matrix4",
"Rotation",

string = {fields = {"split", "trim"}},
table = {fields = {"copy", "getn", "indexof", "insert_all", "key_value_swap"}},
Expand All @@ -43,3 +45,12 @@ globals = {
"_",
}

-- Busted-style unit testing
-- Note: Assumes that luacheck is invoked from the project root, as is done for CI
files["games/devtest/mods/unittests/*.lua"] = {
read_globals = {
"describe",
"it",
assert = {fields = {"same", "equals"}},
},
}
64 changes: 64 additions & 0 deletions games/devtest/mods/unittests/bustitute.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- A simple substitute for a busted-like unit test interface

local bustitute = {}

local test_env = setmetatable({}, {__index = _G})

test_env.assert = setmetatable({}, {__call = function(_, ...)
return assert(...)
end})

function test_env.assert.equals(expected, got)
if expected ~= got then
error("expected " .. dump(expected) .. ", got " .. dump(got))
end
end

local function same(a, b)
if a == b then
return true
end
if type(a) ~= "table" or type(b) ~= "table" then
return false
end
for k, v in pairs(a) do
if not same(b[k], v) then
return false
end
end
for k, v in pairs(b) do
if a[k] == nil then -- if a[k] is present, we already compared them above
return false
end
end
return true
end

function test_env.assert.same(expected, got)
if not same(expected, got) then
error("expected " .. dump(expected) .. ", got " .. dump(got))
end
end

local full_test_name = {}

function test_env.describe(name, func)
table.insert(full_test_name, name)
func()
table.remove(full_test_name)
end

function test_env.it(name, func)
table.insert(full_test_name, name)
unittests.register(table.concat(full_test_name, " "), func, {random = true})
table.remove(full_test_name)
end

function bustitute.register(name)
local modpath = core.get_modpath(core.get_current_modname())
local chunk = assert(loadfile(modpath .. "/" .. name .. ".lua"))
setfenv(chunk, test_env)
test_env.describe(name, chunk)
end

return bustitute
Loading