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) | | |
+------------------+ +-------------------+ +------------------+
- Getting Started
- API Compatibility Layers
- Cartridge Resource Hints (
--#...) - Compilation Pipeline
- Key Language & Compiler Features
- Supported Lua Language Features
- Hardware I/O & Compiler Intrinsics
- Inline Assembly (
__asm__&__rawasm__) - Memory Map Reference
- Compiler Quirks, Assumptions & Known Limitations
- Compiler Optimization
- Roadmap / Not Yet Implemented
- AI Utilization
Requirements
- A C toolchain (
gcc/clang+make), or CMake 3.13+ as an alternative build (see Building with CMake) - If changes are made to the lexer/parser, the
flexandbisontools are needed to regenerate the lexer/parser C routines. The existingflexandbisonC routines are included in the repository to simplify the building process, reducing some dependency on actually needingflex/bisonin most scenarios - The Vircon32 DevTools toolchain (assembler and
packrom) if you intend to go all the way from.luato a runnable.v32cartridge, 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:
makeThis 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" belowctest 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
endCompile it with:
$ v32lua -o program.asm program.luav32lua 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] fileAvailable options:
-o <file>: Specifies the assembly output filename. Defaults to the input filename with its extension replaced by.asm.-g: Generates a companion.debugfile 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.p8is PICO-8, a.ticis TIC-80, and a.luauses its own--#api/--#p8hint — or, with none, its entry points (TIC()→ tic80;_draw()/_update()/_update60()→ pico8; otherwise native).--title "text": Cartridge title, used as is. Without it: the--#titlehint, 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--#titleopthint, 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/--#titleoptstill wins).-b: build a Vircon32 BIOS rather than a cartridge. The program needs afunction error_handler()(the console runs it on a CPU hardware error) besidesmain(), exactly one--#textureand at most one--#sound. The generated assembly starts with the hardware error vector and uses the BIOS ROM page (0x10000000), the XML<rom>getstype="bios", and the build scripts callv32lua -bandassemble -b. Native vircon32 API only.--no-build-scripts: don't writeMake.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).--p8rateis 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/--#soundpaths 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 insrc/and the output in a sibling such asobj/) instead of beside the output (see The cartridge XML).--allow-float-as-clearcolor: a Lua number given toioports.gpu.clearor TIC-80cls()keeps its float32 bits as the clear color (old behavior; used for translucent-clear streaks such aswitchem_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 cartInput 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- Native Vircon32 API (default) — direct, zero-cost access to the
console's own IOPorts:
ioports.gpu.*,ioports.spu.*,ioports.inp.*,music.*/sfx.*,system.*,rect()/rectfill(), the nativetilemap.*API, a full keyboard through a v32kbd device (key()/keyp(), typed text withkbd.read()) and a mouse through a v32mouse device (mouse(),mouse.*). Fully documented in doc/API.md. - TIC-80 compatibility layer (
--#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 ownWAVES/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), andmouse()a real mouse through a v32mouse device.map()takes all of TIC-80's optional arguments includingscale(not the remap callback);fget()returns a boolean andfset()takes one.peek/peek1/peek2/peek4,poke/poke1/poke2/poke4,memcpyandmemsetwork 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 ofmget/mset, the buttons andfget/fset. Other writes are stored but don't change the screen or sound.pmemhas 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. - PICO-8 compatibility layer (
--#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-basedsin/cos, ...),sspr,split,tostr/tonum,_ENV[name], the PICO-8 syntax shortcuts (?,\,f"str", button glyphs, ...),sfx/musicplaying 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/sseton an emulated 64 KB RAM in PICO-8's layout (map, flags, pen, camera, buttons live; screen writes drawn),cartdata/dget/dsetsaved on the memory card, the devkit mouse (stat(32..34), plus TIC-80'smouse()) 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.p8cart 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.luacan 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). |
--#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). |
--#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), for key()/keyp()/kbd.* (default 2; see doc/API.md). |
--#mouse 0–3 |
Gamepad port of a v32mouse mouse (v32io adapter or the modified emulator required, see Keyboard and mouse), for mouse()/mouse.* and PICO-8's stat(32..34) (default 3; see doc/API.md). |
--#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). |
--#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). |
--#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
endWhen 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"- Included files are spliced in at the chunk's genuine top level — no
wrapping in
do...endor a function body — so a top-levellocalin an included file behaves identically to one declared in the entry file. - Include targets are resolved by searching, in order: the directory of the
including file; the compiler's current working directory; each entry of
the
V32LUA_INCLUDEenvironment 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: seeinc/config.h, or Installing for the CMake build). That is wheremake sysinstallandcmake --installput the standard-library ports fromlib/, so any project can--#include "string.lua"without copying the library around. Last comeinclude/v32luaand../Vircon32/v32tools/include/v32luanext to thev32luaexecutable itself: that is how a release zip, or an install under another prefix, finds its own library. Absolute paths are used verbatim. - Cyclic includes are detected, and each resolved file is included at most once across the whole expansion.
- Cart resource hints (
--#texture,--#sound,--#tilemap) declared inside an included file get correct resource IDs and XML ordering. - Error messages inside an included file report the correct source file and line number, via an internal line-remapping table.
- Two included files that each declare a top-level
localof the same name share one global — identical to what the equivalent monolithic code would do.
Compilation Flow
- Lexical & Syntax Analysis: Flex/Bison parses the Lua source into a typed Abstract Syntax Tree (AST).
- Symbol & Scope Resolution: Resolves variables across lexical
scopes, mapping globals to sequential RAM addresses and locals to
[BP - offset]stack frame positions. - Code Generation: Emits Vircon32 assembly instructions, applying hardware intrinsic substitutions as it walks the AST.
- Cartridge Assembly: Emits the final
.asmfile, embeds runtime support routines, generates the read-only string data section, and outputs the.xmlcartridge definition.
Compilation Stages (-v)
When -v is enabled, v32lua reports its progress through its pipeline
stages:
- Stage 1: Lexer — Tokenizes the Lua source, stripping standard
comments and processing string escape sequences (
\n,\t,\r,\\,\"). - Stage 2: Preprocessor — Expands
--#includedirectives and evaluates cartridge hints (--#...) and custom comment syntaxes. - Stage 3: Parser — Constructs a complete Abstract Syntax Tree (AST) using a LALR(1) Bison grammar with strict operator precedence (PEMDAS + logic core).
- Stage 4: Semantic Analyzer — Executes a pre-pass to register global function and variable symbols and initialize the global scope.
- Stage 5: Emitter — Traverses the AST to generate Vircon32 assembly, applying register allocation and scope offsets, and finally outputs the cartridge XML configuration file.
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 agame_loop()function, the compiler automatically generates a continuous runtime harness. The CPU callsgame_loop(), stalls execution for the current frame using the CPU'sWAITinstruction, 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 amain()function, the CPU will halt upon completion of themain()function, mimicking behaviour similar to C'smain()function. If you desire to maintain execution, you must establish some sort of game loop, and you must perform the neededWAITinstructions to ensure continuous processing and smooth display of on-screen assets. The compiler tracks whether aWAITinstruction is emitted insidemain(); if it is missing,v32luaissues a semantic warning at compile time.
Additionally, as a precursor to either of the above:
- Initialization Hook: In both models, if an
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.*, orsystem.*bypasses table lookup routines entirely. They compile directly to hardware port operations (such asGPU_DrawingPointXorTIM_FrameCounter). -
music.*/sfx.*: The native sound API compiles calls likemusic.play(SOUND, channel, loop)down to a straight-lineOUTsequence 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--#tilemapcart 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-gdebug flag generates a companion.debugfile 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]). Address0is reserved for the heap pointer and addresses1/2are reserved scratch words used by the float-to-string routine; ordinary global variables begin at address3. -
Local Variables: Declared with the
localkeyword. Scoped lexically to the enclosing block (function bodies, loops, or conditionals) and mapped to stack offsets ([BP - offset]). Alocaldeclared 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 tolocals 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 valuesBitwise 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:
- Native / TIC-80: integers. Operands are floored and taken modulo
2^32, so
0xFFFFFFFFand-1are 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 & 0xFFis 255,~0is -1,0xFF << 24is 4278190080.>>is logical; a shift of 32 or more gives 0. - PICO-8: 16.16 fixed point, exactly as PICO-8 does it — fractions take
part (
0.5 | 1is 1.5,~0is -1/65536),>>is arithmetic, and PICO-8's^^(xor),>>>(logical right),<<>/>><(rotate), the compound forms (&= |= ^^= <<= >>= >>>= <<>= >><=), theband/bor/bxor/bnot/shl/shr/lshr/rotl/rotrfunctions and0b1010binary literals are all accepted. Hex literals0x8000–0xffffare 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:
- Method Definition Desugaring: Defining a function on a table automatically generates a mangled label and links the function pointer property:
function Player.move(dx, dy) --[[ body ]] end
-- Desugars to: Player["move"] = __function_Player_move- Method Call Desugaring (
:operator): Using the colon operator automatically evaluates the table expression and injects it as an implicitselfparameter:
Player:move(5, -2)
-- Desugars to: Player.move(Player, 5, -2)Control Flow
-
Loops:
while ... do ... end,repeat ... until ..., numericfor i = a, b [, step]and genericfor k, v in pairs(t)/ipairs(t)(or your own iterator function), each with its own block scope. -
Loop Control:
breakstatements 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 usualgoto continue/::continue::idiom works, including the same label name in several loops of one function. -
Conditionals:
if <cond> then ... elseif <cond> then ... else ... endstructures with short-circuit branching.
Operators & Expressions
-
Arithmetic:
+,-,*,/(mapped to Vircon32 floating-point hardware instructionsFADD,FSUB,FMUL,FDIV),%,^,//(floor division), and unary minus. A string that holds a number is converted in arithmetic, as in Lua ("5" + 1is 6). There is no error for other operands ("abc" + 1,nil + 1): the result is meaningless. -
Relational:
==,~=(via__builtin_eqwith NaN unboxing),<,>,<=,>=(via hardwareFLT,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, sot["a" .. "b"]findst.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_lento 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 ifregmatches0xFFC00000(Nil) or0xFFC00001(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 endThe 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 (onlynil/falseare falsy) rather than a raw non-zero test. -
CIF(Cast Integer to Float): Emitted immediately after executing anINinstruction from integer hardware ports, ensuring the value is immediately usable as a Lua number. -
Boolean port reads: decoded into the boxed
true/falserepresentation rather than a raw0.0/1.0float, since0.0is 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. |
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
.asmfile. -
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, thexandyposition on the screen. -
A bare
return(no value) is accepted, inmain()and in other functions, including inside anifor a loop; the function returnsnil. -
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(), ormain()). Not having amain()orgame_loop()function leads to a compile error.game_loop()automatically issues aWAITbefore calling itself again, making it your natural game-loop location — similar to other fantasy consoles (like theTIC()function in TIC-80, which is similarly required). -
v32luais 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
localdeclared in a block lexically outside any function (a baredo...end, or anif/while/forbody 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(), andnext()as a callable function (pairs()works)- Multi-value lists are capped at 32 values: a call's return values, a
spread
...orunpack(t)beyond that are dropped (f(...),{g()},return x, ...,local a, b = ...all expand, up to 32). - Bitwise operators work on 32-bit words (numbers are float32):
x << 32and 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.formatas a method (("%d"):format(x)) — usestring.format(...).- Garbage collection: the heap is a bump allocator, so every table,
closure and runtime string lives until reset. Long-running games should
reuse tables rather than create them per frame. Strings take one word
per character, so building a long string one piece at a time
(
s = s .. cin a loop) uses memory quadratically: nanoman decodes its levels that way and fills the 4M-word RAM after about 100 s of play. - Smaller PICO-8 audio, further: a cart's sound is now its 64 SFX at 22050 Hz (Celeste: 18 MB, from 66 MB). Sequencing single notes instead of whole SFX would roughly halve that again (about half of Celeste's notes repeat), at the cost of a note-level sequencer.
- PICO-8:
pal/palt(compile to no-ops with a warning),clip, realstatvalues (beyond the mouse), fractionalsprwidths,pget, multi-cart loading (reloadfrom 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 sound - TIC-80:
tri/trib,elli/ellib,clip,font,map()'s remap callback; thesfx()speed argument andmusic()'s tempo/speed/sustain arguments (see doc/TIC80.md).peek/pokework on an emulated RAM, but writing the screen, palette, tiles or sound registers has no visible or audible effect.tracedoes nothing. - TIC-80
circ/circb/rectbdraw one GPU quad per pixel: a cart that draws many large outlines each frame (witchem_up's title screen) runs below full speed - A diagnostic (warn/error) for reading, from inside a function, a
localdeclared 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.