A Vircon32-assembly targeting lua compiler
| Spanish version / en español | French version / en francais |
Target Architecture: Vircon32 Fantasy Console (32-bit)
Implementation Language: C (Flex/Bison + Custom Semantic Emitter)
Repository: github.com/wedge1020/v32lua
API Reference: doc/API.md — the full native Vircon32
API (sound, graphics, input, tilemaps, memory card, and raw I/O ports).
Start with its Quick reference: every
intrinsic and every ioports.* port in one place.
Building a cartridge: doc/USAGE.md — command line,
--# hints, the generated XML, and the Vircon32 tools that finish the
job.
v32lua is a Lua compiler written in C that targets the Vircon32
fantasy console. Instead of embedding a heavyweight bytecode interpreter,
v32lua parses Lua source code and compiles it directly into native
Vircon32 assembly, and also produces the XML cartridge definition the
console’s toolchain needs to package a ROM.
While it is not yet complete, one aim of development is to make v32lua
a substitute (by no means a replacement) for the Vircon32 C Compiler in
the Vircon32 development stack. Basically, pick your choice of language
— C or Lua — and once it’s compiled down to assembly, you proceed
with the build regardless of implementation language. As a result,
efforts have been made to mimic various behaviours of the Vircon32 C
Compiler to make compiler substitution more transparent.
Designed from the ground up with retro fantasy-console constraints in
mind, v32lua features zero-cost low-level “hardware” intrinsics, custom
NaN-boxing, and — beyond the native Vircon32 API
— two API compatibility layers so that carts written for TIC-80 and
PICO-8 can compile and run in the Vircon32 environment with little to
no source modification.
+------------------+ +-------------------+ +------------------+
| Source (.lua) | --> | Lexer & Parser | --> | AST Construction |
+------------------+ | (Flex / Bison) | +------------------+
+-------------------+ |
v
+------------------+ +-------------------+ +------------------+
| Cartridge Config | <-- | Vircon32 Assembly | <-- | Semantic Emitter |
| (.xml) | | Emitter (.asm) | | |
+------------------+ +-------------------+ +------------------+
--#...)__asm__ & __rawasm__)Requirements
gcc/clang + make), or CMake 3.13+ as an
alternative build (see Building with CMake)flex and bison tools are
needed to regenerate the lexer/parser C routines. The existing flex and
bison C routines are included in the repository to simplify the building
process, reducing some dependency on actually needing flex/bison in
most scenariospackrom) if you intend to go all
the way from .lua to a runnable .v32 cartridge, plus
v32sim if you want to run or debug
the results (also used for unit tests).Building the Compiler
The repository includes a root-level Makefile that manages building the compiler binary, running the test suite, and general project upkeep. To build the main compiler binary from source, run the default target from the repository root:
make
This will compile the individual source files and produce the
v32lua binary (under bin/), which turns a .lua source file
into a Vircon32 .asm file plus an accompanying cartridge .xml.
From there, assembling and packing follows the same steps as
any other Vircon32 project (assemble → packrom → run under
v32sim or on the official emulator).
Reference Table of Makefile Targets
| Target | Description |
|---|---|
all |
Default target. Builds the compiler (bin/v32lua) inside src/. |
clean |
Removes build artifacts from src/ and generated files from testing/ and demos/. |
install |
Copies bin/v32lua to ~/bin/bin.$(ARCH)/ if that exists, else to ~/bin/. |
sysinstall |
System-wide install: bin/v32lua to /usr/local/bin/, the man page to /usr/local/share/man/man1/, the --#include library (lib/*.lua) to /usr/local/Vircon32/v32tools/include/v32lua/ and the documents to /usr/local/Vircon32/v32tools/docs/v32lua/ (usually needs sudo; DEST_LANG= picks the documents’ language, PREFIX= the location: see Installing). |
sysuninstall |
Removes what sysinstall put down. |
tests |
Runs the unit tests in testing/: each program is compiled, assembled, packed and run on v32sim, and its results are checked against the EXPECTED OUTPUT block in the file (needs the DevTools, v32sim and v32lua in your PATH). |
asmcheck |
Compiles the tests and assembles every result (.vbin), checking the generated assembly. |
v32check |
Like asmcheck, and also packs every test into a .v32 cartridge. |
demos |
Builds every demo under demos/ (see Building the demos). |
version |
Prints the version from inc/v32lua.h and stamps it into the man pages and doc/DEBUGGING.md (run before a release, then rebuild). make version VERSION=YYYYMMDD-status writes a new version into inc/v32lua.h first. |
monofiles, context, put |
Gather the sources into put/ as single text files, for pasting into a conversation. |
archive |
Cleans, then zips the project into v32lua-project.zip. |
The version string lives in one place, #define VERSION in
inc/v32lua.h; v32lua --version prints it and make version copies it
into the man page and the debugging guide. The CMake build and the release
workflow read it from the same line, and a release build stops if the man
page or the debugging guide still carries another version.
For those who prefer it, CMakeLists.txt builds the same compiler from
the same sources (the Makefile stays the primary build, and make tests
is still the full test run on v32sim):
cmake -S . -B build # Release by default
cmake --build build # build/v32lua
ctest --test-dir build # compile every test program
sudo cmake --install build # see "Installing" below
ctest checks that every program in the testing/ categories make
tests runs compiles, and that the ones in testing/fail/ are refused;
running them is make tests’ job. flex and bison are used when found
(-DV32LUA_REGENERATE_PARSER=OFF skips them); otherwise the
already-generated src/parser.c, inc/parser.h and src/lexer.c are
compiled as they are, so a plain build needs only a C compiler and CMake
3.13+. On Windows, build with 64-bit MinGW-w64 (for example from an
MSYS2 MINGW64 shell, cmake -S . -B build -G "MSYS Makefiles"); MSVC
isn’t supported. cpack --config build/CPackConfig.cmake makes a
.tar.gz (plus .deb / .rpm where dpkg-deb / rpmbuild are
available), or a .zip on Windows.
A system-wide install puts everything where a Vircon32 setup expects it.
The binary and the man page go where commands and manual pages go; the
--#include library and the documents go under v32tools/, the folder
the community tools share (with v32opt
and v32c++), next to the official DevTools/:
| Linux / macOS | Windows (CMake) | |
|---|---|---|
| compiler | /usr/local/bin/v32lua |
C:\Program Files\Vircon32\v32tools\v32lua.exe |
| man page | /usr/local/share/man/man1/v32lua.1.gz (gzipped) |
...\v32tools\docs\v32lua\v32lua.1 |
--#include library (lib/*.lua) |
/usr/local/Vircon32/v32tools/include/v32lua |
...\v32tools\include\v32lua |
| documentation | /usr/local/Vircon32/v32tools/docs/v32lua |
...\v32tools\docs\v32lua |
Either build does it:
sudo make sysinstall # Linux / macOS
sudo make sysuninstall
sudo cmake --install build # any system (prefix: /usr/local,
sudo cmake --build build --target uninstall # or C:/Program Files/Vircon32)
The library directory is compiled into the compiler, so after installing,
--#include "string.lua" works from any directory. With CMake, pick a
different location at configure time (cmake -S . -B build
-DCMAKE_INSTALL_PREFIX=/opt/vircon32) and the compiler will look there;
-DV32LUA_INSTALL_INCLUDEDIR=... (and BINDIR, MANDIR, DOCDIR) move
one part alone. Uninstalling removes only v32lua’s own files, and then
v32tools/ (and on Linux / macOS Vircon32/) only if nothing else is
left in it. On Windows, add C:\Program Files\Vircon32\v32tools to your
PATH, as for the DevTools. The Makefile takes PREFIX= (sudo make
sysinstall PREFIX=/opt/vircon32, and DESTDIR= to stage a package); the
compiler finds a library installed that way next to itself (see below),
without a rebuild.
Documentation language. By default both builds install the documents
in every language (README.md, README.es.md, README.fr.md,
doc/API.es.md, …) and the English man page (man/v32lua.es.1 and man/v32lua.fr.1 are the
translated ones). DEST_LANG=es or
DEST_LANG=fr (sudo make sysinstall DEST_LANG=es, or cmake -S . -B
build -DDEST_LANG=es) installs that language instead: the translated
README, doc/API.md and doc/USAGE.md under those plain names (with the
links between them adjusted), next to the guides that exist only in
English (doc/PICO8.md, doc/TIC80.md, …), and the man page in that
language (man/v32lua.es.1, man/v32lua.fr.1).
DEST_LANG=en installs only the English documents.
Release zips. The prebuilt zips on the
Releases page (Windows
x86_64 and arm64, macOS, Linux, Raspberry Pi, and OpenBSD and Haiku when
those builds succeed) are laid out like the Windows install: v32lua
(v32lua.exe) beside include/v32lua/ (the --#include library) and
docs/v32lua/ (the READMEs, doc/ and the man page, in every language).
On Windows, unpack one into C:\Program Files\Vircon32\v32tools;
anywhere else, keep the binary and its include/ folder together. The
compiler looks for --#include files in include/v32lua next to itself
when they are found nowhere else, so an unpacked zip finds its library
wherever it is.
make install instead copies just the binary to ~/bin. The Makefile
build’s defaults (the library directory, the default sample rates and
gamepad ports, …) live in inc/config.h; change one
there and rebuild, or override it when building:
make CFLAGS="-Wall -Wextra -g -I ../inc -DYYDEBUG=1 -DV32LUA_INCLUDE_PATH='\"/opt/v32tools/include/v32lua\"'"
Each demo under demos/pico8/ and demos/tic80/ has a Makefile that
compiles, assembles and packs the cart into bin/<demo>.v32. If the
v32opt assembly optimizer is in
your PATH, the same run also builds an optimized cart, bin/<demo>Opt.v32
(from obj/<demo>Opt.asm and obj/<demo>Opt.xml, the XML twin v32lua
writes beside every XML). The optimized steps are
allowed to fail: if v32opt or the assembler rejects the optimized code,
make prints the error and carries on, and the regular cart is still
built. Without v32opt the optimized build is skipped with a note; force
it off with make HAVE_OPTIMIZER=, or change the flags with
make OPTIMIZER="v32opt -O2".
Your First Cartridge
--#title "v32lua Tech Demo"
--#version "1.0"
--#texture tex_logo "logo.png"
x_pos = 160.0
y_pos = 120.0
speed = 2.5
function init()
-- Set background clear color using zero-cost GPU port mappings
ioports.gpu.bgcolor = 0xFF003366
ioports.gpu.texture = tex_logo -- set texture
ioports.gpu.region = 0 -- set region
-- define the region
ioports.gpu.minX = 0
ioports.gpu.minY = 0
ioports.gpu.maxX = 100
ioports.gpu.maxY = 50
ioports.gpu.hotX = 0
ioports.gpu.hotY = 0
end
function game_loop()
ioports.gpu.clear() -- wipe the screen with bgcolor
-- Update state using pure floating-point math
if ioports.inp.left > 0 then -- > 0: frames held; < 0: released
x_pos = x_pos - speed
elseif ioports.inp.right > 0 then
x_pos = x_pos + speed
end
-- Direct hardware drawing
ioports.gpu.x = x_pos
ioports.gpu.y = y_pos
ioports.gpu.draw()
-- Built-in string concatenation; print() takes x, y first
local frame = system.frames
if frame > 1000 then
print(10, 10, "Demo Running: Frame " .. frame)
end
end
Compile it with:
$ v32lua -o program.asm program.lua
v32lua emits program.asm and program.xml alongside it (and
programOpt.xml, the same cartridge pointing at programOpt.vbin, for a
build through the v32opt optimizer);
hand those to the Vircon32 assembler and packrom to produce a runnable
cartridge. See The cartridge XML for where packrom
expects the assets. It also writes the traditional Vircon32 build scripts,
Make.sh and Make.bat (see Build scripts), unless told
not to with --no-build-scripts.
Command-Line Usage
$ v32lua [options] file
Available options:
-o <file>: Specifies the assembly output filename. Defaults to the
input filename with its extension replaced by .asm.-g: Generates a companion .debug file mapping assembly line offsets
back to original Lua source lines and function entry points.-v, --verbose: Reports progress through the compiler’s internal
pipeline stages as it runs.-d, --debug: Displays additional internal/operational debug
information.-w: Suppresses all compiler warnings.--version: Displays compiler version and author information.--help, -h: Displays command-line usage instructions.Cartridge options — each overrides the matching --# hint in the source,
so a PICO-8 or TIC-80 cart compiles without editing it (--opt=value also
works):
--api pico8|tic80|vircon32: Selects the API layer. Without it the API
is detected: a .p8 is PICO-8, a .tic is TIC-80, and a .lua uses its
own --#api/--#p8 hint — or, with none, its entry points (TIC() →
tic80; _draw()/_update()/_update60() → pico8; otherwise native).--title "text": Cartridge title, used as is. Without it: the --#title
hint, else the cart’s own title (TIC-80’s -- title: metadata, PICO-8’s
first comment line) or the file name — prefixed with [PICO8] or
[TIC80] in those API modes.--titleopt "text": Title of the optimized cartridge (programOpt.xml).
Without it: the --#titleopt hint, else the cartridge title followed by
` [optimized]`.--no-diff-opt-title: the optimized cartridge gets the very same title
as the regular one (an explicit --titleopt / --#titleopt still wins).-b: build a Vircon32 BIOS rather than a cartridge. The program needs
a function error_handler() (the console runs it on a CPU hardware
error) besides main(), exactly one --#texture and at most one
--#sound. The generated assembly starts with the hardware error vector
and uses the BIOS ROM page (0x10000000), the XML <rom> gets
type="bios", and the build scripts call v32lua -b and assemble -b.
Native vircon32 API only.--no-build-scripts: don’t write Make.sh / Make.bat (see
Build scripts).--rate 11025|22050|44100: Sample rate of the sound synthesized from a
PICO-8 or TIC-80 cart (default 22050; see doc/PICO8.md
and doc/TIC80.md). --p8rate is the old name.--bezel art.png: PICO-8 side panels from your own art instead of the
built-in ones (see doc/PICO8.md);
--no-bezel: black margins.--fast-circles: TIC-80/PICO-8 filled circles above radius 31 drawn as
a scaled disc.--xml-preserve-paths: keep the --#texture / --#sound paths in the
cartridge XML as written, and write the XML in the project folder (beside
the source file, or in its parent when the source is in src/ and the
output in a sibling such as obj/) instead of beside the output (see The cartridge XML).--allow-float-as-clearcolor: a Lua number given to ioports.gpu.clear
or TIC-80 cls() keeps its float32 bits as the clear color (old
behavior; used for translucent-clear streaks such as witchem_up).The defaults of these options (sample rates per API, side panels on/off
and a default panel art file, fast circles, warnings) are set in
inc/config.h: change them there and rebuild, or pass them at build time
(make CFLAGS="... -DV32LUA_DEFAULT_PICO8_RATE=11025"). Precedence:
command-line option, then the --# hint in the source, then config.h.
$ v32lua celeste.p8 --title "celeste" --rate 11025 # API detected from .p8
$ v32lua game.tic # TIC-80 binary cart
Input files: .lua, .p8 (PICO-8 cartridge), .tic (TIC-80 cartridge,
Lua carts only; the code and bank 0’s tiles, sprites, map, flags, palette,
waveforms and SFX are read — the same data a TIC-80 .lua export carries).
v32lua supports three distinct API surfaces, selected with the --#api
cartridge hint (native Vircon32 is the default when no --#api hint is
present):
--#api "tic80" -- opt into the TIC80-compatible API surface
--#api "pico8" -- opt into the PICO8-compatible API surface
ioports.gpu.*, ioports.spu.*, ioports.inp.*,
music.*/sfx.*, system.*, rect()/rectfill(), the native
tilemap.* API, a full keyboard through a v32kbd device
(key()/keyp(), typed text with kbd.read()) and a mouse through a
v32mouse device (mouse(), mouse.*). Fully documented in
doc/API.md.--#api "tic80") — TIC-80-shaped calls
(spr(), btn()/btnp(), map()/mset()/mget(), sound/music
functions, and the fantasy-console-style asset sections) compiled down to
native Vircon32 instructions, including the coordinate scaling needed to
map TIC-80’s 240×136 logical screen onto Vircon32’s physical resolution.
sfx()/music() play the cart’s own WAVES/SFX/PATTERNS/TRACKS,
synthesized at compile time by a port of TIC-80’s sound engine (see
doc/TIC80.md); spr() rotates; Start pauses the game (as
on the PICO-8 layer). print() honours its color and returns the text
width. key()/keyp() read a real keyboard through a v32kbd device
(TIC-80 key codes, autorepeat included), and mouse() a real mouse
through a v32mouse device.
map() takes all of TIC-80’s optional arguments including scale
(not the remap callback); fget() returns a boolean and fset() takes
one. peek/peek1/peek2/peek4, poke/poke1/poke2/poke4,
memcpy and memset work on an emulated 96 KB TIC-80 RAM, created on
first use with the cart’s palette, tiles, sprites and map in it; the map
(0x08000), gamepad (0x0FF80) and sprite-flag (0x14404) areas are live
views of mget/mset, the buttons and fget/fset. Other writes are
stored but don’t change the screen or sound. pmem has TIC-80’s 256
32-bit slots on the memory card. Circles on both fantasy-console layers
are one GPU draw each up to radius 31 (pre-rendered at compile time,
pixel-identical to the consoles’). A function the program
defines itself (function pal(...)) replaces the built-in of that name,
as in Lua.--#api "pico8") — the PICO-8-shaped
equivalent: spr/map/mget/mset/fget/fset, cls, rectfill/
rect/circfill/circ/line/pset/print (with color),
camera/color, btn/btnp (PICO-8 autorepeat), add/del/
count/foreach/for v in all(t) (deletion-safe), PICO-8 math
(flr, rnd, mid, turn-based sin/cos, …), sspr, split,
tostr/tonum, _ENV[name], the PICO-8 syntax shortcuts (?, \,
f"str", button glyphs, …), sfx/music playing the cart’s own
__sfx__/__music__ (synthesized at compile time), time/t,
peek/poke (8/16/32-bit, and the @ % $ operators), memcpy/
memset/reload/sget/sset on an emulated 64 KB RAM in PICO-8’s
layout (map, flags, pen, camera, buttons live; screen writes drawn),
cartdata/dget/dset saved on the memory card, the devkit mouse
(stat(32..34), plus TIC-80’s mouse()) through a v32mouse device, and
_init/_update (30 fps)/
_update60/_draw. The 128×128 screen is scaled 2.75× and centered;
drawing outside it is masked. A .p8 cart compiles directly
(v32lua game.p8): its __lua__ section is the program and its
__gfx__/__gff__/__map__ sections become the sprite sheet, sprite
flags and map. A plain .lua can take those assets from a cart with
--#p8 "game.p8". See doc/PICO8.md.Only one API surface is active per cartridge; selecting tic80 or
pico8 replaces the native call surface rather than adding to it.
⚠ Keyboard and mouse input need extra hardware or a modified emulator. The Vircon32 console has no keyboard and no mouse — only four gamepads. v32lua’s keyboard and mouse functions (native
key()/keyp()/kbd.*andmouse()/mouse.*, TIC-80’skey()/keyp()/mouse(), PICO-8’sstat(32..34)) read a keyboard or mouse disguised as a gamepad, and work only with one of:
- the v32io hardware adapter — a Waveshare RP2350-USB-A board (R13 removed) running the v32io firmware: a USB keyboard or mouse plugged into it appears to the computer as a gamepad (
v32io:kbd/v32io:mouse), so it works with any Vircon32 emulator, stock included, once a joystick profile is set up for it;- a modified Vircon32 emulator, which reads the computer’s own keyboard and mouse as v32io devices (no hardware needed): the fork wedge1020/ComputerSoftware,
v32iobranch (bothv32kbdandv32mouse), or the stock DesktopEmulator sources with the v32io project’semulator/v32io/v32kbd.patch,emulator/v32io/v32kbd-text.patchandemulator/v32io/v32mouse.patchapplied.Without one of these, keyboard and mouse access is not possible: programs compile and run, but no key is ever down and the mouse never moves. Programs that need them should say so, and ideally offer gamepad controls too.
The keyboard is read from gamepad port 2 (the third) and the mouse from
port 3 (the fourth) by default, leaving ports 0 and 1 for regular
gamepads (--keyboard N, --mouse N, --#keyboard N, --#mouse N, or
kbd.port(n) / mouse.port(n) at run time). Port numbers count from 0;
the emulator’s menus call port 2 “Gamepad 3” and port 3 “Gamepad 4”. The protocols, the
every-frame reading the compiler adds, and the setup are described in
doc/API.md.
v32lua allows you to embed Vircon32 cartridge metadata directly in your
Lua source code using special --# line comments. The compiler parses
these hints to auto-generate the project’s .xml ROM definition and
assign sequential hardware resource IDs.
Supported hints:
| Hint | Purpose |
| — | — |
| --#version "X.Y" | Sets the cartridge version field in the XML. |
| --#title "TITLE" | Sets the cart title. |
| --#titleopt "TITLE" | Sets the title of the optimized cartridge (gameOpt.xml); default: the cart title followed by ` [optimized]. |
| –#no-diff-opt-title | The optimized cartridge keeps the same title as the regular one (an explicit –#titleopt still wins). |
| –#api “tic80” / –#api “pico8” | Selects a compatibility API layer (see above). |
| –#p8 “cart.p8” | PICO-8: take the sprite sheet, sprite flags and map from a .p8 cart (implies –#api pico8). |
| –#rate 11025 \| 22050 \| 44100 | Sample rate of the sound synthesized from a PICO-8 or TIC-80 cart (default 22050; –#p8rate is the old name; see [doc/PICO8.md](/v32lua/doc/PICO8.html#sound)). |
| –#bezel off \| on \| “art.png” | PICO-8: the side panels beside the 128×128 screen — none, the built-in art, or your own (see [doc/PICO8.md](/v32lua/doc/PICO8.html#side-panels)). |
| –#fast-circles | TIC-80/PICO-8: filled circles above radius 31 are drawn as one scaled disc (faster, edges differ slightly). |
| –#allow-float-as-clearcolor | Same as the command-line option. |
| –#keyboard 0–3 | Gamepad port of a v32kbd keyboard (v32io adapter or the modified emulator required, see [Keyboard and mouse](#keyboard-and-mouse-v32io)), for key()/keyp()/kbd. (default 2; see [doc/API.md](/v32lua/doc/API.html#keyboard-key--keyp--kbd)). |
| –#mouse 0–3 | Gamepad port of a v32mouse mouse (v32io adapter or the modified emulator required, see [Keyboard and mouse](#keyboard-and-mouse-v32io)), for mouse()/mouse. and PICO-8's stat(32..34) (default 3; see [doc/API.md](/v32lua/doc/API.html#mouse-mouse--mouse)). |
| –#texture NAME “path/image.png” | Registers a texture resource and binds it to a compile-time constant NAME. The XML names the .vtex file (image.vtex) that png2vircon makes from the PNG (see [The cartridge XML](#the-cartridge-xml)). |
| –#sound NAME “path/sound.wav” | Registers a sound resource and binds it to a compile-time constant NAME. The XML names the .vsnd file that wav2vircon makes. |
| –#tilemap NAME “path/map.csv” | Registers a tilemap from a CSV file, embedded directly into the ROM image (see [doc/API.md](/v32lua/doc/API.html#tilemap-tilemap)). |
| –#include “file.lua”` | Textually splices another Lua file in at this point, before parsing begins (see below). |
--#version "1.1"
--#title "Space Grinder: Tech Demo"
-- Register textures (automatically binds 'bg_space' to ID 0, 'spr_ship' to ID 1)
--#texture bg_space "assets/background.png"
--#texture spr_ship "assets/player.png"
function init()
-- Variables declared in hints are globally available in Lua at runtime!
ioports.gpu.texture = bg_space
end
When compiled, v32lua outputs both the compiled .asm assembly and a
complete Vircon32 XML cartridge definition file linking .vtex and
.vsnd assets. With this, and the proper processing of any PNG and WAV
data, you can proceed to the packrom step. Resource IDs are assigned in
source order and are guaranteed to match their position in the generated
XML.
packrom reads every path in the XML relative to the XML file itself, so
by default v32lua writes the XML beside the -o file and puts only file
names in it: with -o obj/game.asm and
--#texture SHIP "textures/ship.png" you get obj/game.xml containing
<binary path="game.vbin" />
<texture path="ship.vtex" /> <!-- SHIP -->
and the .vbin, .vtex and .vsnd files are expected beside it, in
obj/. Textures and sounds that v32lua generates itself (PICO-8 and
TIC-80 carts) are written there already. If two assets collapse to the
same name, the later one keeps just enough of its directories to be
unique: textures/ship.png followed by textures/extra/ship.png gives
ship.vtex and extra/ship.vtex (the second expected in obj/extra/);
only two assets whose whole paths collapse to one name (differing by
extension, say) draw a warning.
--xml-preserve-paths restores the paths as written (textures/ship.vtex),
writes the XML into the project folder instead — the source file’s
directory, or its parent when the source is in src/ and the output in a
sibling such as obj/, so -o obj/game.asm src/game.lua gives game.xml
beside src/ and obj/ — and makes <binary> the .vbin path relative
to it (obj/game.vbin). Hint paths are then relative to the XML as well
(src/textures/ship.vtex), and compiler-generated assets are named relative
to the XML’s directory too. (With src/game.lua and -o game.asm, or a
source outside a folder named src, the XML goes in the source’s directory
as before.)
Next to every XML a twin for the optimized build is written
(obj/gameOpt.xml, identical but for <binary path="gameOpt.vbin" /> and
its title, <title> [optimized] unless you set --#titleopt /
--titleopt or ask for the same title with --no-diff-opt-title), ready
for v32opt output; no sed step is needed.
Build scripts (Make.sh / Make.bat)
From the same information as the XML, v32lua writes the traditional
Vircon32 build scripts: Make.sh for Linux/macOS and Make.bat for
Windows. Each creates obj/ and bin/, runs v32lua again (with your
options), converts the --#texture / --#sound files with png2vircon /
wav2vircon, assembles, and packs bin/game.v32, stopping with
BUILD FAILED on the first error and ending with BUILD SUCCESSFUL.
They first check that v32lua is in the PATH (and say it is needed if
not), and build the optimized cart (v32opt, assemble, packrom
gameOpt.xml into bin/gameOpt.v32) only if v32opt is installed.
They are written beside the source file, or in its parent when the source
is in src/ and the output in a sibling folder such as obj/. A
Make.sh / Make.bat that does not begin with the Generated by v32lua
mark is never overwritten. --no-build-scripts disables them. See
doc/USAGE.md.
BIOS builds (-b)
With -b, v32lua builds a Vircon32 BIOS (the console’s boot program)
instead of a cartridge, under the rules of the Vircon32 toolchain itself:
the program defines a parameterless function error_handler(), which the
console runs when the CPU raises a hardware error, besides main() (or
game_loop()); it declares exactly one --#texture and at most one
--#sound; and it uses the native vircon32 API (not pico8 or tic80).
Every violation found is reported at once. The generated assembly then
begins with the hardware error vector (CALL __function_error_handler,
WAIT, HLT) ahead of the normal start, and its ROM addresses lie in the
BIOS page (0x10000000) instead of the cartridge page (0x20000000). The
XML’s <rom> gets type="bios", and the build scripts pass -b to
v32lua and assemble.
Multi-File Projects (--#include)
Because Vircon32 carts are a fixed ROM assembled entirely at build time
— there is no runtime filesystem — v32lua does not support real
Lua’s dynamic require/dofile. Instead, --#include "file.lua" is a
compile-time textual paste, resolved by a preprocessing pass before the
lexer ever sees the file, exactly like C’s #include:
--#include "src/physics.lua"
--#include "src/entities.lua"
do...end or a function body — so a top-level local in an
included file behaves identically to one declared in the entry file.V32LUA_INCLUDE environment variable when set (a colon-separated
list; semicolon-separated on Windows); then the built-in default
include path, /usr/local/Vircon32/v32tools/include/v32lua (set at
build time: see inc/config.h, or Installing for the
CMake build). That is where make sysinstall and cmake --install put
the standard-library ports from lib/, so any project can
--#include "string.lua" without copying the library around. Last come
include/v32lua and ../Vircon32/v32tools/include/v32lua next to the
v32lua executable itself: that is how a release zip, or an install
under another prefix, finds its own library. Absolute paths are used
verbatim.--#texture, --#sound, --#tilemap) declared
inside an included file get correct resource IDs and XML ordering.local of the same
name share one global — identical to what the equivalent monolithic code
would do.Compilation Flow
[BP - offset] stack frame positions..asm file, embeds runtime
support routines, generates the read-only string data section, and
outputs the .xml cartridge definition.Compilation Stages (-v)
When -v is enabled, v32lua reports its progress through its pipeline
stages:
\n, \t, \r, \\,
\").--#include directives and
evaluates cartridge hints (--#...) and custom comment syntaxes.Flexible Execution Models: main() vs. game_loop()
To accommodate different game architecture styles, the compiler supports two distinct function entry points:
The Auto-Waiting Function (game_loop): If your program declares
a game_loop() function, the compiler automatically generates a
continuous runtime harness. The CPU calls game_loop(), stalls
execution for the current frame using the CPU’s WAIT instruction, and
loops infinitely. This is ideal for standard arcade games and demos,
and mimics the behaviour of various other fantasy consoles.
Manual Control (main): If your program declares a main()
function, the CPU will halt upon completion of the main() function,
mimicking behaviour similar to C’s main() function. If you desire to
maintain execution, you must establish some sort of game loop, and you
must perform the needed WAIT instructions to ensure continuous
processing and smooth display of on-screen assets. The compiler tracks
whether a WAIT instruction is emitted inside main(); if it is
missing, v32lua issues a semantic warning at compile time.
Additionally, as a precursor to either of the above:
init() function is
present, it is guaranteed to execute exactly once after top-level
global RAM allocations and before the main loop begins.A program must declare at least one of main() or game_loop() — this
is the designated entry point and its absence is a compile error.
NaN-Boxing: RAM vs. ROM Elements
v32lua uses a 32-bit tagging architecture that packs type metadata and
payload pointers into unified values, keeping immutable ROM elements
(string literals, function pointers) distinct from dynamic RAM heap
objects (tables):
| Data Type | Hex Mask / Tag | Architecture Description |
|---|---|---|
| Nil | 0xFFC00000 |
Canonical representation for undefined/missing values. |
| Boolean False | 0xFFC00001 |
Short-circuit falsy value. |
| Boolean True | 0xFFC00002 |
Short-circuit truthy value. |
| ROM String | 0x7FC00000 |
Pointers to read-only string data sections (__string_%d) in ROM. |
| RAM String | 0xFFC00000 |
Strings built at run time, on the heap (payload 4 and up). |
| Function | 0x7F800000 |
Code address in ROM, or (with bit 21 set) a closure record in RAM. |
| Table | 0xFF800000 |
Boxed heap memory addresses (Bit 31=1, Bit 22=0). |
| Number | IEEE 754 Float | Unboxed native Vircon32 floating-point values for direct math. |
Hardware Intrinsics & I/O Mapping
High-performance Vircon32 games cannot afford hash-table lookups for
hardware manipulation. v32lua intercepts specific table member
expressions and function calls and compiles them directly into native
hardware I/O instructions:
Zero-Cost Hardware Access: Accessing namespaces like
ioports.gpu.*, ioports.spu.*, ioports.tim.*, ioports.rng.*,
ioports.car.*, ioports.mem.*, or system.* bypasses table lookup
routines entirely. They compile directly to hardware port operations
(such as GPU_DrawingPointX or TIM_FrameCounter).
music.* / sfx.*: The native sound API compiles calls like
music.play(SOUND, channel, loop) down to a straight-line OUT
sequence when every argument is compile-time-known, falling back
to a small runtime routine only when arguments are dynamic. See
doc/API.md for the full surface, including why the
SPU port write order (stop → assign → volume → play →
loop/position) is important.
tilemap.*: A native tilemap API (tilemap.get(),
tilemap.set(), tilemap.render()) backed by a --#tilemap cart
hint. Tilemap data ships read-only in ROM and is lazily promoted to a
private RAM copy the first time a given map is written to.
Consolidated Gamepad Polling: Polling controller input is optimized
into a single variable intrinsic (ioports.inp.inputs). The compiler
polls all gamepad axes/buttons, collates the active button states into
a bitshifted 32-bit integer mask, and casts it to a Lua float in
a single register. Standalone gamepad inputs are also available
(ioports.inp.left, ioports.inp.A, etc.) as variable intrinsics.
Built-in Fast Paths: Standard Lua operations like string
concatenation (..), length (#), and unary minus (-) map directly
to optimized runtime subroutines (__builtin_strcat, __builtin_len,
__builtin_unm).
See doc/API.md for the complete, authoritative reference — this README highlights the ideas, the API doc covers every call.
Developer Experience & Debug Tooling
Visual ASCII Error Reporting: Lexical, syntax, semantic, and internal compiler errors print highlighted, multi-line ASCII code snippets pointing directly to the offending line in the source file.
Source-to-Assembly Mapping (-g): Passing the -g debug flag
generates a companion .debug file alongside the output assembly.
This file maps relative Vircon32 assembly line offsets to original
Lua source lines and functional entry points, enabling step-through
debugging under v32sim.
Inline & Raw Assembly Bubbles: You can write native assembly
directly inside Lua using __asm__("your ASM") (which snapshots and
restores registers and the stack pointer) or __rawasm__("your ASM")
for unprotected execution. Both modes support string interpolation of
Lua variables using {var_name} syntax.
v32lua implements a subset of Lua, tailored specifically for game
development on embedded hardware.
Variables & Scoping
Global Variables: Automatically registered in RAM and accessed via
symbols ([var_name], [func_name]). Address 0 is reserved for the
heap pointer and addresses 1/2 are reserved scratch words used by
the float-to-string routine; ordinary global variables begin at address
3.
Local Variables: Declared with the local keyword. Scoped
lexically to the enclosing block (function bodies, loops, or
conditionals) and mapped to stack offsets ([BP - offset]). A local
declared at a chunk’s own top level — outside any function — is
promoted to a global instead, since its storage would otherwise live in
a stack frame that returns before any game code runs; this applies
equally to locals pulled in via --#include.
In Lua parlance, functions are “first-class citizens”, and are
effectively variables. That is borne out in v32lua as they both are
transacted within the NaN-boxing scheme.
Multiple Assignment
The compiler natively supports multiple assignment and variable swapping without requiring explicit user temporaries:
local x, y, z = 10, 20, 30
x, y = y, x -- Synthesizes temporary register chains to safely swap values
Bitwise operators
Lua 5.3/5.4’s &, |, ~ (xor), <<, >> and unary ~ (not). Every
number is a float32, so each operation takes its operands as 32-bit words
and converts the result back:
0xFFFFFFFF and -1 are the same word. The sign of the full
value is carried along, so &, |, ~ match 64-bit Lua for operands in
[-2^31, 2^32): -1 & 0xFF is 255, ~0 is -1, 0xFF << 24 is
>> is logical; a shift of 32 or more gives 0.0.5 | 1 is 1.5, ~0 is -1/65536), >> is arithmetic, and
PICO-8’s ^^ (xor), >>> (logical right), <<> / >>< (rotate), the
compound forms (&= |= ^^= <<= >>= >>>= <<>= >><=), the band/bor/
bxor/bnot/shl/shr/lshr/rotl/rotr functions and 0b1010
binary literals are all accepted. Hex literals 0x8000–0xffff are
negative, as in PICO-8 (0xffff == -1).Limit: a float32 holds 24 significant bits, so a result such as
0xDEADBEEF comes back rounded; masks and packed fields with fewer
significant bits (0xFF000000, 0xF0F0) are exact. Expressions of
literals (1 << 4) are folded at compile time.
Object-Oriented Programming & Tables
v32lua provides seamless syntactic sugar for table-based OOP models:
function Player.move(dx, dy) --[[ body ]] end
-- Desugars to: Player["move"] = __function_Player_move
: operator): Using the colon operator
automatically evaluates the table expression and injects it as an
implicit self parameter:Player:move(5, -2)
-- Desugars to: Player.move(Player, 5, -2)
Control Flow
Loops: while ... do ... end, repeat ... until ..., numeric
for i = a, b [, step] and generic for k, v in pairs(t) /
ipairs(t) (or your own iterator function), each with its own block
scope.
Loop Control: break statements jump immediately to the end label
of the current innermost loop (tracked via an internal compilation loop
stack).
goto / ::label::: Lua 5.2-style labels, block-scoped, with
forward references — the usual goto continue / ::continue:: idiom
works, including the same label name in several loops of one function.
Conditionals: if <cond> then ... elseif <cond> then ... else ...
end structures with short-circuit branching.
Operators & Expressions
Arithmetic: +, -, *, / (mapped to Vircon32 floating-point
hardware instructions FADD, FSUB, FMUL, FDIV), %, ^, //
(floor division), and unary minus. A string that holds a number is
converted in arithmetic, as in Lua ("5" + 1 is 6). There is no error
for other operands ("abc" + 1, nil + 1): the result is meaningless.
Relational: ==, ~= (via __builtin_eq with NaN unboxing), <,
>, <=, >= (via hardware FLT, FLE, FGT, FGE).
Logical: and, or, not (with short-circuit evaluation).
String Concatenation: .. operator automatically pushes operands
and invokes the runtime subroutine __builtin_strcat.
String literals & methods: "double", 'single' and long
[[bracket]] strings (no escapes; a newline right after [[ is
dropped). Number literals accept exponents (1e-3, 2.5E4). String
library calls work as methods on string values — s:sub(2, #s),
("abc"):upper(), s:len(), rep, byte, find, lower,
reverse, gsub — and strings used as table keys compare by content,
so t["a" .. "b"] finds t.ab.
Numbers → strings: whole numbers print without a decimal point
("5"), others with up to 6 significant fractional digits and no
trailing zeros ("0.5", "2.25") — float32 precision.
Length Operator: # operator invokes __builtin_len to resolve
string or table lengths.
Functions & Multi-Value Returns
Arguments are passed on the stack. Functions can return several values:
the first three come back in registers R0, R2 and R3, and any more
go through a reserved RAM buffer. As in Lua, a call or ... at the end
of a list expands to all its values — in arguments (f(a, g())), table
constructors ({g()}), return x, ... and local a, b = ... — up to 32
values. Variadic functions (function f(...)), closures with upvalues
(captured locals shared between the closures that capture them), and
recursion all work.
String Literal Pooling
All string literals declared in source code (e.g., "GAME OVER") are
collected during compilation, deduplicated, and emitted into a dedicated
data section at the end of the ROM (__string_0: string "GAME OVER"),
preventing redundant ROM consumption.
Truthy & Falsy Short-Circuit Evaluation
In Lua, only nil and false evaluate to false in conditional
expressions; every other value (including 0 and empty strings) is
truthy. v32lua implements this via two high-speed assembly emission
primitives:
emit_falsy_jump(reg, label): Tests if reg matches 0xFFC00000
(Nil) or 0xFFC00001 (False). If either matches, execution jumps to the
target label.
emit_truthy_jump(reg, label): Tests against Nil and False; if
neither matches, execution short-circuits to the target label.
When logical operators (and, or) are evaluated, the evaluated result
is left intact in the destination register, preserving Lua’s idiom of
returning the actual operand value rather than a strict boolean.
Metatables
setmetatable, getmetatable, rawget, rawset, rawlen and
rawequal. These events are supported:
__index: a table, possibly with its own metatable (so class chains
work), or a function (t, k).__newindex: a table or a function (t, k, v), used only for keys
the table doesn’t have, as in Lua.__call: calling the table, which is passed as the first argument.__tostring, __len, __metatable.Point = {}
Point.__index = Point
function Point.new(x, y) return setmetatable({x = x, y = y}, Point) end
function Point:len2() return self.x * self.x + self.y * self.y end
The metatable is consulted only when a read finds nothing, or a write
adds a new key. Tables without a metatable run the same code as before.
The arithmetic, comparison and concatenation events (__add, __eq,
__lt, __concat, …) are not supported: operators compile to bare
float instructions, and checking each one for a table would slow down all
arithmetic.
One of the most powerful features of v32lua is its static
intrinsic interception engine. When the compiler encounters table
accesses or function calls matching specific system paths (e.g.,
ioports.gpu.clear()), it bypasses dynamic table lookups entirely
and emits direct Vircon32 hardware I/O instructions (IN, OUT).
Automatic Type Casting Across I/O Boundaries
Because Lua variables are stored as NaN-boxed IEEE 754 floats while
Vircon32 hardware ports expect 32-bit integers or booleans, v32lua
automatically injects hardware conversion instructions during port reads
and writes:
CFI (Cast Float to Integer): Emitted automatically when writing
numeric values to integer GPU/Input ports.
CFB (Cast Float to Boolean): Emitted when writing boolean flags to
hardware registers, decoding Lua truthiness (only nil/false are
falsy) rather than a raw non-zero test.
CIF (Cast Integer to Float): Emitted immediately after executing
an IN instruction from integer hardware ports, ensuring the value is
immediately usable as a Lua number.
Boolean port reads: decoded into the boxed true/false
representation rather than a raw 0.0/1.0 float, since 0.0 is
truthy in Lua and would otherwise make a disconnected gamepad or memory
card read as “connected”.
Comprehensive Intrinsics Reference Table
The full, authoritative reference for every intrinsic — ioports.gpu.*,
ioports.inp.*, ioports.spu.*, ioports.tim.*, ioports.rng.*,
ioports.car.*, ioports.mem.*, music.*/sfx.*, tilemap.*,
memcard.*, and system.* — lives in doc/API.md, including
port-ordering caveats, call signatures, and worked examples. Its
Quick reference lists every intrinsic and
every ioports.* port (all of ioports.gpu.* included) on one page. A
short sample of the most commonly used entries:
GPU Control & Drawing (ioports.gpu.*)
| Lua Path / Intrinsic | Vircon32 Port / Command | Access | Description & Behavior |
|---|---|---|---|
ioports.gpu.texture |
GPU_SelectedTexture |
Read / Write | Sets or reads the active texture ID used for drawing operations. |
ioports.gpu.region |
GPU_SelectedRegion |
Read / Write | Selects the texture sub-region (sprite frame) to render. |
ioports.gpu.x / ioports.gpu.y |
GPU_DrawingPointX/Y |
Read / Write | Screen coordinates for drawing placement. |
ioports.gpu.minX/minY/maxX/maxY |
GPU_RegionMin/MaxX/Y |
Read / Write | Defines the pixel boundaries of the active texture region. |
ioports.gpu.hotX/hotY |
GPU_RegionHotSpotX/Y |
Read / Write | Sets the drawing origin (hotspot) relative to the sprite region. |
ioports.gpu.draw([mode]) |
GPU_Command |
Function Call | Executes a hardware draw command: "zoom", "rotate", "rotozoom", or default. |
ioports.gpu.clear([color])ioports.gpu.clear(r, g, b [, a]) |
GPU_ClearColor + GPU_Command |
Function Call | Sets the clear color and wipes the screen. Supports preset color strings ("black", "white", "blue", "red", "green"), a packed 0xAABBGGRR value (a literal, or rgba()/hex() for a runtime value), or separate components: clear(r, g, b [, a]), each 0–255, alpha defaulting to opaque. |
rgba(r, g, b [, a]) |
— | Intrinsic | The packed 0xAABBGGRR word (raw, not a Lua number) for spr()’s color_mult, ioports.gpu.clear(color), ioports.gpu.multiply / bgcolor. Components clamped to 0–255 and truncated, alpha defaults to 255; folded at compile time when all are literals. Keep colors as components and call rgba() where they’re drawn — see doc/API.md for why. |
color(n) |
— | Intrinsic | A number holding a packed color (computed, or read from a table) as the raw word. Floored and wrapped to 32 bits; literals are exact, runtime values keep float32’s 24 significant bits (details). |
rect(x1, y1, x2, y2 [, color])rectfill(x1, y1, x2, y2 [, color]) |
GPU_Command (zoomed draw) |
Intrinsic | Rectangle outline / filled, between two inclusive corners in any order; color a packed word as for spr() (default white). One GPU draw for rectfill, up to four for rect; the GPU state is restored afterwards (details). |
Gamepad & Input (ioports.inp.*)
| Lua Path / Intrinsic | Vircon32 Port / Command | Access | Description & Behavior |
|---|---|---|---|
ioports.inp.gamepad |
INP_SelectedGamepad |
Read / Write | Selects the active controller index (0-3) for input polling. Reads return the last index set (the emulators can’t read the port back). |
ioports.inp.status |
INP_GamepadConnected |
Read Only | Returns a Lua boolean: is the selected gamepad connected. |
ioports.inp.left/right/up/down |
INP_Gamepad* |
Read Only | D-Pad directional state (> 0 pressed, < 0 released). |
ioports.inp.A/B/X/Y/L/R/START |
INP_GamepadButton* |
Read Only | Action/shoulder button state (> 0 pressed, < 0 released). |
ioports.inp.inputs |
Custom Action Subroutine | Read Only | Collation intrinsic: polls all gamepad buttons/axes in one pass, collates them into a single 32-bit bitmask, and casts it to a Lua float. |
key([k]), keyp([k [, hold, period]]) |
v32kbd device on a gamepad port | Intrinsic | Only with a v32io adapter or the modified emulator (why). A full keyboard through a v32kbd device (gamepad port 2 by default, --#keyboard N): key held / went down this frame, TIC-80 style, with key codes or literal names ("a", "enter", "shift") (details). |
kbd.read(), kbd.event(), kbd.glyph(v), kbd.port([n]), kbd.capslock(), kbd.connected(), kbd.clear() |
v32kbd device | Intrinsic | Typed text (Shift and Caps Lock applied), raw press/release events, the keyboard’s port, Caps Lock, device present, drop unread events. |
mouse() |
v32mouse device on a gamepad port | Intrinsic | Only with a v32io adapter or the modified emulator (why). A mouse through a v32mouse device (gamepad port 3 by default, --#mouse N): x, y, left, middle, right, scrollx, scrolly, as TIC-80’s (scroll always 0) (details). |
mouse.pressed/released([b]), mouse.buttons(), mouse.delta(), mouse.position([x, y]), mouse.bounds(...), mouse.scale([n]), mouse.port([n]), mouse.connected() |
v32mouse device | Intrinsic | Button edges this frame, buttons held, movement this frame, the pointer, its limits and speed, the mouse’s port, device present. |
System & Runtime Utilities
| Lua Path / Intrinsic | Vircon32 Instruction | Access | Description & Behavior |
|---|---|---|---|
system.halt() |
HLT |
Function Call | Emits the hardware HLT instruction, immediately terminating CPU execution or freezing the frame until the next interrupt/frame cycle. |
system.wait() |
WAIT |
Function Call | Emits the hardware WAIT instruction, pausing execution until the next interrupt/frame cycle. |
system.frames() / system.cycles() |
TIM_FrameCounter / TIM_CycleCounter |
Read Only | Frames since power-on / CPU cycles used so far this frame (the () is optional). |
print(x, y, value) |
__builtin_tostring + __builtin_print |
Function Call | Converts value to a string and draws it on screen with the BIOS font, at pixel position x, y. |
__asm__ & __rawasm__)For performance-critical inner loops or advanced Vircon32 hardware
manipulation, v32lua provides direct inline assembly injection.
Standard Inline Assembly (__asm__)
The __asm__ directive allows embedding raw Vircon32 assembly strings
directly inside Lua functions. Crucially, it supports variable
interpolation, enabling seamless bridging between Lua scope symbols and
assembly registers:
local speed = 5.0
__asm__("MOV R0, {speed}\nFADD R0, 1.5\nMOV {speed}, R0")
How it works: Any identifier wrapped in braces (e.g., {speed}) is
replaced at compile time by the variable’s memory operand: [BP - n]
for a local, [BP + n] for a parameter, [var_speed] for a global.
The code must be one string literal (write line breaks as \n). A
local that a closure captures holds a pointer to its box rather than
the value.
Each line of interpolated assembly is passed through the compiler’s
formatting engine, ensuring consistent indentation and comment alignment
in the output .asm file.
This standard inline assembly applies some mild guardrails and protections, in the form of backing up any existing used registers along with the stack. While it doesn’t prevent problems, it may help mitigate some caused by accident. Any register changes made here are lost outside the inline “bubble”.
Raw Assembly (__rawasm__)
The __rawasm__ directive outputs the literal string directly to the
assembly stream without safeties applied. This can be quite dangerous, and
should only be used by the most knowledgeable and experienced of assembly
users. It is also the basis of the compiler’s own unit-test harness: a
test file is typically a function main() ... end wrapper around a
sequence of __rawasm__ blocks with __debugN: labels for breakpointing
under v32sim
| RAM Address | Designation | Usage |
|---|---|---|
0 |
HEAP_POINTER |
Stores the dynamic starting address for runtime table/string allocations. |
1, 2 |
FTOA_SCRATCH_PTR_A/B |
Reserved scratch words used by the float-to-string conversion routine. |
3 to HEAP_START - 1 |
Global RAM | Sequentially allocated slots for global Lua variables, resource IDs, and promoted top-level locals. |
HEAP_START and up |
Dynamic Heap | Runtime memory managed by the table allocator and string routines. |
Stack Top (SP) |
Call Stack | Function activation records, local variables, and saved register states. |
HEAP_START is computed after all code generation has finished, so
top-level statement codegen that registers late globals can never collide
with the heap.
Program ROM is not part of this table: a cartridge’s code and constant data
sit at 0x20000000 (V32_CART_PAGE), those of a BIOS built with -b at
0x10000000.
While v32lua attempts to be a functional Lua compiler, it by no means is
a full-to-specification implementation of the language. For one, there’s
no bytecode virtual machine, nor interpreter — Lua compiles straight down
to native assembly.
Further, there are some explicit deviations from a standard implementation of the language to better suit the freestanding environment of Vircon32:
print() requires, as its first two parameters, the x and y
position on the screen.
A bare return (no value) is accepted, in main() and in other
functions, including inside an if or a loop; the function returns nil.
Program execution MUST reside within a function. While you can declare
functions, you must use one of the designated starting points to begin
the chain of execution (init(), game_loop(), or main()). Not
having a main() or game_loop() function leads to a compile error.
game_loop() automatically issues a WAIT before calling itself again,
making it your natural game-loop location — similar to other fantasy
consoles (like the TIC() function in TIC-80, which is similarly
required).
v32lua is a float-only implementation: there is no Lua 5.3+
integer/float dual-number type. All numbers are IEEE-754 float32, so
integers above 2^24 cannot be represented exactly — worth keeping in
mind for bit-manipulation-heavy code.
A local declared in a block lexically outside any function (a bare
do...end, or an if/while/for body at chunk level) currently has
no compiler-level guardrail if a function later reads that name — see
the roadmap item below.
Clearly, this effort is focused on making a tool for development on Vircon32, and not on being a fully-compliant Lua implementation. Efforts will be made to come as close as is possible and feasible, without sacrificing significant performance or veering away from being the tool it is intended to be.
Early on in compiler development, all optimization code was removed and factored into a separate tool, v32opt. This is designed as a general purpose Vircon32 assembly optimizer, meant for use with the C compiler and lua compiler (along with handwritten assembly). Early tests have shown some mild improvements to performance, and potential space savings by eliminating redundant instructions.
At time of writing this tool is still very much in development, but is showing
promise and will likely work for standard scenarios under the -O1, -O2,
and even -O3 optimization levels. It is meant to be inserted into the build
chain after the compiling and before assembling. The demo Makefiles use it
when it is installed, building an optimized bin/<demo>Opt.v32 next to the
regular cart (see Building the demos and
doc/USAGE.md).
The following are known, deliberate gaps rather than bugs — either deferred to keep initial development moving, or awaiting a design decision:
pcall/error/assertstring.match/gmatch; the metatable events __add/__sub/… , __eq/__lt/__le,
__concat, __unm (see Metatables)select(), and next() as a callable function (pairs() works)... or unpack(t) beyond that are dropped (f(...),
{g()}, return x, ..., local a, b = ... all expand, up to 32).x << 32
and wider results, and >> of a negative number, differ from Lua’s
64-bit integers, and a result with more than 24 significant bits
(0xDEADBEEF) is rounded. See Bitwise operators above.string.format as a method (("%d"):format(x)) — use
string.format(...).s = s .. c in a loop) uses memory quadratically: nanoman decodes its
levels that way and fills the 4M-word RAM after about 100 s of play.pal/palt (compile to no-ops with a warning), clip,
real stat values (beyond the mouse), fractional spr widths, pget,
multi-cart loading (reload from another file, cstore);
writing sprite/sound memory has no effect and reading screen memory
returns only what was written there (no GPU read-back); the SFX
editor’s filter switches in synthesized soundtri/trib, elli/ellib, clip, font,
map()’s remap callback; the sfx() speed argument and music()’s
tempo/speed/sustain arguments (see doc/TIC80.md).
peek/poke
work on an emulated RAM, but writing the screen, palette, tiles or sound
registers has no visible or audible effect. trace does nothing.circ/circb/rectb draw one GPU quad per pixel: a cart that
draws many large outlines each frame (witchem_up’s title screen) runs
below full speedlocal declared in a block lexically outside any function at chunk
level (see above)NOTE: There was extensive AI use and interaction throughout this effort. A distinction should be made from “vibe coding”, but there is definitely a blur between human and AI. In the end, both benefit and could compensate for the other’s deficiencies.
This endeavour actually was not primarily about developing a compiler, it began as an honest attempt to get a feel for AI and its impact: its role and detriment to human thinking and education. That it has a compiler theme was merely to accentuate a point of interest. It has certainly been a learning experience. If sufficient compiler concepts and background knowledge weren’t sufficiently known going into this, the effort would have ended far less successfully.