Dark Mode

author:  zep
updated: 2023-06-05 (WIP!)


Userdata in Lua is a general-purpose data type that represents a fixed size block of memory. It can be thought of as a reference to a raw memory allocation, but is garbage collected like any other Lua type; there is no need to explicitly free memory allocated as userdata.

Picotron provides methods for constructing and manipulating userdata viewed as a 1d or 2d array of typed elements:

u = userdata("i16", 4, 8)  --  a 4x8 array of 16-bit signed integers
u:set(0,0,3)               --  set the first element (x=0, y=0) to 3
?#u                        --  the total number of elements (32)

Userdata in Picotron can be used to efficiently represent 0-based arrays, images, vectors, and matrices.

▨ Constructing Userdata

userdata(type_str, width, [height], [data_str])

The userdata() function returns a 1d or 2d userdata, with elements of type type_str:

"f64" 64-bit floating-point
"i64" 64-bit signed integer
"i32" 32-bit signed integer
"i16" 16-bit signed integer
"u8"  8-bit  unsigned integer

The second and third arguments give the userdata's width and height. When the third argument is not a number (or not given), the userdata is taken to be 1-dimensional:

u = userdata("u8",8192) -- 8k of bytes

■ Initial Values

If a string is given as the last argument, it is taken to be hexadecimal data specifying initial values (for integer types only):

-- 2 32-bit signed integers with values 0x12345678 and 0xcafef00d
u = userdata("i32", 2, "12345678cafef00d")

Alternatively, a PICO-8 sprite string ("[gfx]...[/gfx]") can be passed as a single argument to get a 2d array of unsigned bytes:

u = userdata"[gfx]08080400004000444440044ffff004f1ff1004fff9f0444769749447770900a00a00[/gfx]"

set(ud, [x], [num])
set(ud, [x], [y], [num])

Write one or more elements starting from x, y.

The number of arguments depends on the dimensionality of the userdata.

set(u, 5,5, 1,2,3,4,5) -- write 5 values in reading order starting from 5,5

set() is part of the userdata's metatable, so it can also be called using metatable method syntax:

u:set(5,5, 1,2,3,4,5) -- write 5 values in reading order starting from 5,5

■ Vectors

A convenience function vec() can be used to create 1d f64 userdata:

v = vec(3,4,5)
-- is equivalent to:
v = userdata("f64",3)

▨ Accessing Userdata

// All userdata indices start at 0.

get(ud, [x], [num])
get(ud, [x], [y], [num])

Returns one or more elements.

The number of arguments depends on the dimensionality of the userdata.

v = vec(3,4,5)
local x,y   = v:get(0, 2) -- first 2 elements
local x,y,z = v:get()     -- all elements

Out of range accesses return 0.

■ Flat Indices

A single flat index can be used to read the nth element:

v = userdata("u8", 3, 3)
v:set(0,0, 1,2,3,4,5,6,7,8,9)
?v[0] -- 1
?v[6] -- 7
v[6] = 100 -- same as v:set(0,2,100)

Unlike :get(), accessing out of range indices in this way returns nil.

■ Named Indices

The first 3 elements of a userdata can be retrieved with .x, .y and .z:

v = vec(3,4,5)
?v.y -- 4

■ Retrieiving Userdata Size

?vec(1,2,3):height()     -- nil, because is 1 dimensional

Userdata Operations

Operations on userdata are applied per-element:

v = vec(1,2,3) + vec(4,4,4)
?v -- (5.0, 6.0, 7.0)

Supported operators for any userdata type are: + - * / %

Operators for integer userdata types are: & | ^^

Each operator has a corresponding function that can be used directly for finer control:

:add :sub :mul :div :mod :band :bor :bxor

An additional :copy(_, ...) method is equiavalent to :add(0, ...). There is no corresponding operator for this as regular assignment (v = v2) applies to the userdata object itself.

userdata_op(u0, u1, u2, offset1, offset2, len, stride1, stride2, spans)

All parameters are optional.

Applies {userdata_op} (add, mult etc) to each element and written to a new userdata:

?vec(1,2,3):add(vec(4,4,4)) -- same as ?vec(1,2,3) + vec(4,4,4)

u1 can be a scalar in which case it is treated as a userdata with a single element and stride of 0. In other words, that number is used as the RHS operand for each element:

v = vec(1,2,3)
v = v:add(10) -- add 10 to each element
v += 10       -- same thing

u2 is an optional output userdata, which can be the boolean value true to mean "write to self":

v:add(10, v)    -- add 10 to each element of v, written in-place
v:add(10, true) -- same thing

offset1 is the flat index of u1 to read from, and offset2 is the flat index of u2 (or u0) to write to. When a scalar is given as u1, offset1 is ignored and assumed to be 0.

len is the number of elements to process

■ Stride

The last 3 parameters (stride1, stride2 and spans) can be used together to apply the operation to multiple, non-contiguous spans of length len. stride1, and stride2 specify the number of elements between the start of each span. Both are expressed as flat indices (i.e. for 2d userdata to indicate the element at x,y, the flat index is x * width + h).

This is easier to see with a visual example:

foo = userdata("u8", 4, "01020304")

function _draw() cls() circfill(240 + t()*10,135,100,7) get_display():add(foo, true, 0, 0, 4, 0, 16, 10000) end

This is an in-place operation -- reading and writing from the display bitmap (which is a 2d userdata).

It modifies the first 4 pixels in each group of 16, 10000 times (so 40000 pixels are modified).

First, 1 is added to the first pixel, 2 to the second, up to the 4th pixel. The second span starts at the 16th pixel, and reading again from the start of foo (because the stride for u1 is 0), which means the same 4 values are added again and again.

Note that this example is a pure userdata operation -- no graphical clipping is performed except to stay within the range of each userdata.

■ CPU Costs

Operations on userdata all cost 1 cycle for every 8 operations, except for div and mod which cost 1 cycle for every 2 operations.

Matrices and Vectors

Matrices and vectors can be represented as 2d and 1d userdata of type f64:

mat = userdata("f64", 4, 4)
set(mat, 0, 0,
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, 1, 0,
  0, 0, 0, 1)

matmul(m0, m1, [m_out])

Multiply two matrixes together. matmul is part of the userdata metatable, and it is more usual to see it in the form: m0:matmul(m1).

When m_out is given, the output is written to that userdata. Otherwise a new userdata is created of width m1:width() and height m0:height().

As per standard matrix multiplication rules, the width of m0 and the height of m1 must match -- otherwise no result is returned.

mat = mat:matmul(mat)
v2  = vec(0.7,0.5,0.5,1):matmul(mat) -- vector width matches matrix height

matmul3d(m0, m1, [m_out])

For 3d 4x4 transformation matrices, :matmul3d can be used.

matmul3d implements a common optimisation in computer graphics: it assumes that the 4th column of the matrix is (0,0,0,1), and that the last component of LHS vector (the mysterious "w") is 1. Making these assumptions still allows for common tranformations (rotate, scale, translate), but reduces the number of multiplies needed.

matmul3d can be used on any size vector as only the first 3 components are observed, and anything larger than a 3x4 userdata for the RHS matrix; again, excess values are ignored.

So apart from the cpu and space savings, matmul3d can be useful for storing extra information within the same userdata (such as vertex colour or uv), as it will be ignored by matmul3d(). matmul() is less flexible as it requires unambiguous matrix sizes.

See /demos/landscape.p64 for an example.

■ vector methods

:cross(v, [v_out])

■ matrix methods

:matmul(m, [m_out])
:matmul2d(m, [m_out])
:matmul3d(m, [m_out])

like the per-component operation methods, v_out or m_out can be "true" to write to self.

Mapping Userdata to RAM

Userdata of integer types can be given an address in RAM:

ud = userdata("u8",8192)
memmap(0x80000, ud)
ud[3] = 55
?@0x80003 -- 55

memmap(addr, ud)

addr is the starting memory address, which must be in 4k increments (i.e. end in 000).

ud is the userdata to map. When ud is nil or not given, any mapping at that address is released (and the userdata can be garbage collected if it has no further references).

Userdata does not need to be sized to fit 4k boundaries, with one exception: addresses below 0x10000 must always be fully mapped, and memmap calls that break that rule have no effect.