v32lua: Vircon32 Lua Compiler

A Vircon32-assembly targeting lua compiler

v32lua: Vircon32 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)    |     |                  |
+------------------+     +-------------------+     +------------------+

Table of Contents


Getting Started

Requirements

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.

Building with CMake

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.

Installing

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\"'"

Building the demos

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:

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):

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).


API Compatibility Layers

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

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 (v32io)

⚠ 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.* and mouse()/mouse.*, TIC-80’s key()/keyp()/mouse(), PICO-8’s stat(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, v32io branch (both v32kbd and v32mouse), or the stock DesktopEmulator sources with the v32io project’s emulator/v32io/v32kbd.patch, emulator/v32io/v32kbd-text.patch and emulator/v32io/v32mouse.patch applied.

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.


Cartridge Resource Hints

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.

The cartridge 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"

Compilation Pipeline

Compilation Flow

  1. Lexical & Syntax Analysis: Flex/Bison parses the Lua source into a typed Abstract Syntax Tree (AST).
  2. Symbol & Scope Resolution: Resolves variables across lexical scopes, mapping globals to sequential RAM addresses and locals to [BP - offset] stack frame positions.
  3. Code Generation: Emits Vircon32 assembly instructions, applying hardware intrinsic substitutions as it walks the AST.
  4. Cartridge Assembly: Emits the final .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:

  1. Stage 1: Lexer — Tokenizes the Lua source, stripping standard comments and processing string escape sequences (\n, \t, \r, \\, \").
  2. Stage 2: Preprocessor — Expands --#include directives and evaluates cartridge hints (--#...) and custom comment syntaxes.
  3. Stage 3: Parser — Constructs a complete Abstract Syntax Tree (AST) using a LALR(1) Bison grammar with strict operator precedence (PEMDAS + logic core).
  4. Stage 4: Semantic Analyzer — Executes a pre-pass to register global function and variable symbols and initialize the global scope.
  5. Stage 5: Emitter — Traverses the AST to generate Vircon32 assembly, applying register allocation and scope offsets, and finally outputs the cartridge XML configuration file.

Key Language & Compiler Features

Flexible Execution Models: main() vs. game_loop()

To accommodate different game architecture styles, the compiler supports two distinct function entry points:

Additionally, as a precursor to either of the above:

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:

See doc/API.md for the complete, authoritative reference — this README highlights the ideas, the API doc covers every call.

Developer Experience & Debug Tooling


Supported Lua Language Features

v32lua implements a subset of Lua, tailored specifically for game development on embedded hardware.

Variables & Scoping

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:

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
Player:move(5, -2)
-- Desugars to: Player.move(Player, 5, -2)

Control Flow

Operators & Expressions

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:

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:

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.

Hardware I/O & Compiler Intrinsics

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:

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.

Inline Assembly (__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")

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


Memory Map Reference

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.


Compiler quirks and assumptions

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:

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.

Compiler Optimization

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).

Roadmap / Not Yet Implemented

The following are known, deliberate gaps rather than bugs — either deferred to keep initial development moving, or awaiting a design decision:


AI utilization

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.