VML tutorial: from your first program to a small game
This page goes step by step: every step can be followed, and the code can be pasted and run as-is. If you have not read the VML overview yet, skimming it first takes no time.
Contents
① Where to run it
Two entry points, one set of compilers, one virtual machine.
Mobile (Android / iOS)
- The Command tab — just type the command:
vml test # run the built-in self-test program
vml run examples/c/old/old_std_primes.c # compile and run
vml make proj.vmk # build from a project file; produces output only, does not run
vml run dispatches on the file extension: .vml goes to the assembler,
.vmb loads bytecode directly, and the other 27 languages use their own front-end compiler.
The path is resolved against the working directory shown on the Command tab.
- The Files page — tap a file and you get two actions:
| File | What "Compile" produces | "Run" |
|---|---|---|
A high-level source file (.c / .py / .rs …) | .vml assembly | Compiles, then runs it |
.vml (assembly) | .vmb bytecode (runs faster) | Assembles, then runs it |
.vmb (bytecode) | — already final | Runs it directly |
The examples/ directory that comes with the app is the sample directory, split by language:
examples/c/, examples/basic/, examples/python/ and so on.
Desktop
On desktop you use vmlcli, a command-line tool in the source repository. It is the same
compile chain as mobile, only much faster — get the syntax and the logic right on your computer
first, and leave the last step to the phone. From the repository root:
# Build once
dotnet build scripts/vmlcli -c Release
# Compile and run
dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/old/old_std_primes.c --timeout 20
# Compile only (write the assembly out, do not run)
dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/old/old_std_primes.c --vml out.vml
⚠ Writing a C program on a phone takes over a minute (front-end compilation + assembly + linking, more than thirty thousand instructions). That is normal — do not take it for a hang. The same chain on desktop takes one or two seconds.
② Your first program
We will use a sample that really exists in the repository:
Examples/c/old/old_std_primes.c (on mobile the path is
examples/c/old/old_std_primes.c). It is a prime sieve — a large static array, nested
loops and memset: the most typical C there is. Here is the whole file; its header comments are
bilingual, which is the convention in that directory:
/* old_std_primes.c —— 素数筛(命令行的,数组 + 循环)
* old_std_primes.c -- prime sieve (command line, arrays + loops)
*
* 类别:std
* Category: std
* 兼容面:**大数组**(静态 1 万个元素)、嵌套循环、`memset(..., sizeof(数组))`
* Compatibility: a **large array** (10,000 static elements), nested loops, and `memset(..., sizeof(array))`
* 这条最常见的 C 惯用法、用「每 10 个换行」这种老式排版
* (that most common C idiom), plus old-style layout such as "a newline every 10 items"
* 出处:自写,仿《C 程序设计语言》里那种筛法练习。
* Origin: self-written, imitating the sieve exercise found in "The C Programming Language".
*
* ⚠ 本文件**刻意保持老程序的原样写法**(`sizeof(flags)` 而不是显式长度)——
* ⚠ This file **deliberately keeps the old program's original style** (`sizeof(flags)` instead of an explicit length) --
* 它就是用来钉 `sizeof(<数组>)` 这条的:修复前该处返回元素大小(4),
* it exists to pin down the `sizeof(<array>)` item: before the fix that spot returned the element size (4),
* 于是 `memset` 只清 4 个字节、筛法一个素数都筛不出(实测「共 2 个」)。
* so `memset` cleared only 4 bytes and the sieve found no primes at all (measured: "2 in total").
* 详见 `third_party/vml/FRONTEND_DEFECTS.md`。
* See `third_party/vml/FRONTEND_DEFECTS.md` for details.
*/
#include <stdio.h>
#include <string.h>
#include <vml_ui.h> /* 只为 ui_scr_get_language() —— 老程序不该猜系统语言 */
/* only for ui_scr_get_language() -- an old program must not guess the system language */
#define LIMIT 10000
/* ⚠ 放**静态区**而不是栈上:老程序里这种大表都是全局的 ——
* ⚠ Put it in **static storage** rather than on the stack: large tables like this were always global in old programs --
* 一来当年栈很小,二来这正是"全局数组"那条兼容面(局部大数组另有一条)。
* first, the stack was tiny back then; second, this is exactly the "global array" compatibility surface (large local arrays are a separate one).
*/
static char flags[LIMIT + 1];
int main(void)
{
int i, j, count = 0;
int lang; /* 界面语言:开局查一次 */
/* UI language: queried once at start */
lang = ui_scr_get_language();
memset(flags, 1, sizeof(flags));
flags[0] = flags[1] = 0;
for (i = 2; i * i <= LIMIT; i++) {
if (!flags[i]) continue;
for (j = i * i; j <= LIMIT; j += i)
flags[j] = 0;
}
printf(lang == 0 ? "%d 以内的素数:\n" : "Primes up to %d:\n", LIMIT);
for (i = 2; i <= LIMIT; i++) {
if (!flags[i]) continue;
count++;
printf("%5d", i);
if (count % 10 == 0) printf("\n"); /* 老式排版:每 10 个换行 */
/* Old-style layout: a newline every 10 items */
}
if (count % 10 != 0) printf("\n");
printf(lang == 0 ? "共 %d 个\n" : "%d in total\n", count);
return 0;
}
Run it:
# Mobile, on the Command tab
vml run examples/c/old/old_std_primes.c
# Desktop (repository root)
dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/old/old_std_primes.c --timeout 20
It starts with Primes up to 10000: (or the Chinese line on a Chinese UI), then right-aligned
numbers in five columns, ten per line, and the last line reads 1229 in total — there really
are 1229 primes up to 10000, and that number is the check.
The two ui_scr_get_language() lines are asking what language the UI is in: Chinese UI prints
Chinese, English UI prints English. An old program should not guess.
By the way: compiling and running C and VML assembly is free; the compilers for the other languages are part of the Full Edition. That is why the first example on this page is C.
③ Core concepts
The file extension picks the language
There is no build script to write and no compiler to declare — the extension is the language:
.c goes to the C front end, .py to the Python front end, .rs to the
Rust front end, and so on through all 22 (plus .vml for the assembler and .vmb for
loading bytecode directly). On desktop you can force a front end with --lang <name>, and
-P lists every front end together with the extensions it accepts.
Registers are always written with @
When writing VML assembly (or reading what a compiler generated) there is one convention unlike any other
assembler: registers always start with @ — including the registers inside memory
operands:
push @R15
push @R12
move @R12 @R13 ; new style (everything generated looks like this)
move @R0 [@R12-8] ; registers inside memory-operand strings carry @ too
call f1 ; labels do **not** take @
ret
push R15 ; old style: still readable, and exactly equivalent
move R0 [R12-8] ; old style: bare names and bare memory operands
| Old style (still readable, equivalent) | New style (everything generated uses this) |
|---|---|
move R0, R1 | move @R0, @R1 |
push R12 / pop R15 | push @R12 / pop @R15 |
move R0, [R12-8] | move @R0, [@R12-8] |
move R0, [R1] (register indirect) | move @R0, [@R1] (@ inside the brackets too) |
move R0, [var_x] | move @R0, [var_x] (labels take no @) |
call f1 | call f1 (writing call @R0 is an indirect call) |
move R0, #42 | move @R0, #42 (immediates take no @) |
Why the convention exists — it was forced by a real defect. The assembler used to decide "is this a
register?" by shape: a prefix of R / F / D / L
followed by something that parses as an integer counted as a register (case-insensitively). So
short names chosen by users ran straight into it:
int f1(void) { return 7; }
int f2(void) { return 11; }
int main(void) { print_int(f1()); print_int(f2()); return 0; } /* measured: printed 77, should be 7 11 */
call f1 was read as call R1 and call f2 as call R2; names
like d1 and l1 were worse — they map to the registers numbered 17 and 25, past
the end of the register file, and crashed. All 27 front ends were affected.
The fix was not "guess from context" (call f1 could indeed be decided that way, but when
a label is used as a value — move @R0, f1 takes f1's address — it is
textually indistinguishable from the register F1), but to mark registers explicitly at the
source, when generating them. Three rules come with it:
| Rule | Covers | |
|---|---|---|
| (a) | An operand in a label position (CALL / JMP / conditional jumps / LABEL) is a label | call f1 |
| (b) | If any @-marked register appears in an instruction, its other bare "letters + digits" tokens are labels | move @R0, f1 (taking an address), call @R0 (indirect call) |
| (c) | Write @ on every register when generating (including inside memory operands: R12-8 → @R12-8) | So that (b) can always apply |
⚠ Never mix the two dialects inside one instruction: (b) says "a @ present
⇒ the other bare tokens are labels", so the R0 in move [@R1+8] R0 would be
taken as a label. Either mark the whole instruction (move [@R1+8] @R0) or mark none of it
(move [R1+8] R0).
When a name collides with a register, who wins
For a register-shaped token without @ (f1 / d2 /
R1 / r100), ownership is decided by one rule:
If this file defines a label with that name, it is a label; otherwise it is a register; if it is neither a label nor a valid register, it is an error.
The decision is deferred until the whole file has been parsed, because labels may be referenced before
their definition. In other words: variables, functions, parameters and array names may all be called
f1 / d1 / R2 — they are treated as labels. The repository
keeps a sample that pins this down: Examples/c/test/regname.c, where twelve register-shaped names
add up to a fixed value (831); if the total is wrong, some variable was taken for a register. On mobile:
vml run examples/c/test/regname.c.
Generated register references always carry @, so the cost of this rule falls only on
hand-written assembly: if you really do define a label called R0, then [R0] is a
label reference, and register indirection must be written [@R0].
The argument area and where parameters live
VML's parameter passing has long since been unified into a single shape: every argument goes on the stack, none of them in registers (the old "first four arguments in R0–R3" fast path was dropped), and arguments are pushed right to left.
The stack grows downwards, so the last one pushed sits at the lowest address — which gives one position that always holds:
- The first argument is always at
[R12+12], then one 4-byte slot each:[R12+16],[R12+20]… - In other words: parameter i lives at
[R12+12+4i](i counting from 0). - Each slot is exactly 4 bytes; 8-byte types (
double/long) take two slots.
In practice you almost never need to compute that offset yourself. In C inline assembly, use the
${parameter} placeholder and the front end loads it for you:
asm("SYSCALL #6") /* old: relied on "the first parameter is in R0" -- no longer true after the ABI change */
asm("SYSCALL #6, ${val}") /* new: expands to move R0 [R12+12]; syscall #6 */
⚠ ${} only recognises local variables (parameters count as locals). Using it
on a global variable degrades to R12+0 and draws the wrong thing silently.
⚠ One more: ${} counting restarts at R0 in every asm() — split the
placeholders across two asm() calls and the second one overwrites the first. Put several
placeholders in the same asm().
This is also why you should not hand-roll multi-argument system calls in C: the whole layer for graphics, audio and saved data (see the next section) is already written to this convention. Use the wrapper functions.
④ Drawing and windows (ui_*)
Remember the layering first: for newly written programs, text mode is tty_* and graphics
mode is ui_*. conio / crt / graphics (BGI) are
compatibility layers that fill in the environment for old programs; new programs should not use them
directly.
The shape of a frame
The call order is essentially fixed: ask for the usable drawing area → open the window (the window size is the canvas coordinate space) → per frame "clear + draw + submit" → wait for input on one message queue.
#include <vml_ui.h>
int msg[4];
int W = ui_scr_get_w(); /* must be asked **before** ui_win_open */
int H = ui_scr_get_h();
ui_win_open("My window", W, H);
ui_2d_clear(0xFF202030); /* clear (colour is 0xAARRGGBB, alpha first) */
ui_2d_rect(40, 40, 80, 40, 0xFF4ADE80, 1, 0, 4);
ui_2d_present(); /* ⚠ every frame must call this, or the screen stays empty */
while (ui_win_is_closed() == 0) {
if (ui_msg_wait(msg, 0) == 0) continue; /* nothing to do this round */
/* msg[0] = message type, msg[1] onwards are the arguments */
}
The functions you will actually use
| Function | Notes |
|---|---|
ui_scr_get_w() / ui_scr_get_h() | Usable drawing area — must be asked before ui_win_open |
ui_win_open(title, w, h) | Open a window |
ui_win_close() / ui_win_is_closed() | Close / has a close been requested |
ui_2d_clear(color) | Clear the canvas |
ui_2d_rect(x, y, w, h, color, fill, lw, radius) | Rectangle (radius is the corner radius) |
ui_2d_circle(cx, cy, r, color, fill, lw) | Circle |
ui_2d_line(x1, y1, x2, y2, color, lw) | Line |
ui_2d_ellipse(cx, cy, rx, ry, color, fill, lw) | Ellipse |
ui_2d_text(x, y, s, color, size, anchor) | Text (anchor VML_ANCHOR_LEFT / VML_ANCHOR_CENTER) |
ui_2d_present() | Submit this frame |
ui_msg_wait(msg, timeout_ms) | Take one message (touch / keys / timer on the same queue) |
ui_timer_set(interval_ms, tag) | A repeating timer; each expiry posts one VML_MSG_TIMER |
ui_audio_beep(freq, ms) / ui_vibrate_simple(ms, strength) | Sound / vibration |
ui_scr_set_keep_on(on) | Keep the screen awake |
ui_store_set(key, value) / ui_store_get(key, buf, cap) | Key/value save data |
Colours are always 0xAARRGGBB (alpha first); 0xFFRRGGBB is opaque. Text is passed
to the host as UTF-8, so a source file saved as UTF-8 can contain non-ASCII text directly.
Input: use the built-in gamepad, do not draw your own
The drawing window already has a row of on-screen gamepad buttons along the bottom; a program should
only receive VML_MSG_KEYDOWN / VML_MSG_KEYUP. Drawing your own D-pad plus touch
hit-testing costs three things at once: it takes about 140px of window height, the geometry has to be
computed twice ("draw" and "hit test", so changing the spacing gives you "it looks like I am on the button
but tapping does nothing"), and every game draws its own version that users have to learn again.
The key constants are the Win32 virtual-key values (the same set a physical keyboard delivers): arrows
37–40, Enter 13, Space 32, A/B/X/Y are simply the letters 65/66/88/89, VML_KEY_ESCAPE = 27 and
VML_KEY_SELECT = 16.
⑤ The minimal skeleton of a small game
Write a skeleton that runs first (open a window + a timer + a message loop + one square), and only then add the gameplay. Writing the whole thing in one go and then debugging it is the slowest route in this domain. What follows is the minimal skeleton that runs — a square bouncing left and right:
#include <vml_ui.h>
#include <stdlib.h>
int W, H, TICK; /* canvas size, cell size */
int x, y, vx; /* game state lives in **file-level globals** */
void draw_all(void) {
ui_2d_clear(0xFF202030); /* background */
ui_2d_rect(x, y, TICK, TICK, 0xFF4ADE80, 1, 0, 4);
ui_2d_present(); /* **must be called once a frame is drawn**, or nothing shows */
}
int main(void) {
int msg[4];
int t;
W = ui_scr_get_w(); if (W <= 0) W = 360; /* **ask for the drawing area before opening the window** */
H = ui_scr_get_h(); if (H <= 0) H = 620;
ui_win_open("My Game", W, H);
ui_scr_set_keep_on(1); /* do not let the screen sleep while playing */
TICK = 24; x = 100; y = 100; vx = 2;
draw_all();
ui_timer_set(40, 0); /* ⚠ **without this line the game never moves** */
while (ui_win_is_closed() == 0) {
t = ui_msg_wait(msg, 0);
if (t == 0) continue;
if (t == VML_MSG_WINDOWCLOSE) break;
if (t == VML_MSG_TIMER) {
x = x + vx;
if (x < 0 || x > W - TICK) vx = -vx;
draw_all(); /* redraw every tick */
continue;
}
if (t == VML_MSG_KEYDOWN) {
if (msg[1] == VML_KEY_ESCAPE) break;
/* key handling */
}
}
ui_scr_set_keep_on(0);
ui_win_close();
return 0;
}
Once the skeleton runs, pick an existing game and read or change it. All of these live under
examples/:
| File | What to look at |
|---|---|
Examples/c/app/mario.c | Side-scrolling platformer: camera, integer physics, rectangle collision, stomping enemies, coins, a goal, three lives |
Examples/c/app/tetris.c | Grid game: shape tables, line clears, speed-up by level, high-score save, pause |
Examples/c/app/gomoku.c | Turn-based: tap the board to place, a simple AI, win/lose dialog (the cleanest template) |
Examples/c/app/pacman.c | The standard way to do grid movement, plus its own flood-fill verification |
Examples/c/app/starfall.c | Visually the richest: gradient background, three parallax star layers, particle explosions, screen shake, drag-to-control |
Examples/cpp/app/gorilla.cpp | Ballistics + wind + buildings that collapse; the most complete sound design of all (eight events, each with its own pitch and length) |
Examples/basic/app/gorilla.bas | The BASIC version of the same game, with the same sound design as the C++ one |
Examples/go/app/snake.go · Examples/swift/app/plane.swift · Examples/csharp/app/racer.cs | Similar games in other languages — the interfaces and the skeleton are exactly the same |
Examples/fortran/app/sokoban.f90 · Examples/lua/app/life.lua · Examples/rust/app/breakout.rs | More languages, the same ui_* interfaces |
Examples/c/ui/draw_prims.c | A check-up of every drawing primitive (one cell each for rectangle / circle / polygon / path / gradient) |
Examples/c/app/tilt.c | All three sensors (accelerometer / gyroscope / fused orientation) drawn on screen; copy it to write tilt controls |
Examples/c/app/piano.c | Multi-touch and polyphonic audio |
⚠ Two are known not to run yet, and they are not new problems:
Examples/python/app/tetris.py (that front end can read lists but writing to them has no
effect, so the board cannot be stored) and Examples/basic/app/tetris.bas (a few subroutine defects
in the BASIC front end). Both games ship in C for now.
⑥ Common traps
Every one of these was found after the code was written. Symptom and cause are listed together so you can match them up:
| Trap | Symptom | Cause |
|---|---|---|
Forgetting ui_2d_clear() | The picture smears (each frame piles on the last), then it crashes after ten seconds or so | Every drawing call appends to the same primitive list; only clearing empties it |
Forgetting ui_timer_set | The window appears but nothing moves | All the logic is in the timer branch; with no timer there are only key messages |
Forgetting ui_2d_present() | An empty screen, or it stays on the previous frame | Drawing goes into a back buffer and has to be submitted |
| Laying out for the screen size but opening a different one | The content is cut off on the right | The window size is the canvas coordinate space; always call ui_scr_get_w/h() before opening |
| Assuming key-up always arrives | After you let go, the character keeps running | KeyUp does get lost (a finger slides off the button, or the system swallows the event). You must build in a brake: a new key takes over, and holding longer than N ticks releases automatically |
Calling ui_scr_set_keep_on(1) and forgetting to undo it | The screen never sleeps after you quit the game | Add ui_scr_set_keep_on(0) on the exit path |
| Firing a run of sound effects | You only hear the last one | ui_audio_beep is single-channel (each new tone cuts off the previous one) ⇒ one sound per event |
| A grid game moving N pixels per tick | The character gets stuck half a cell short: it will not turn, will not pick things up, and it looks like the keys are dead | Once the position crosses a grid line the cell index reports the next cell, and "the wall is ahead" pins the character half a cell from the centre |
A few settled rules
- One sound per event. The single-tone
ui_audio_beep(freq, ms)is enough, and structurally it cannot clip. The mechanical rules for pitch and length: take the first note of the original figure (MIDI converted to Hz); for an outcome take the extremes at both ends (the highest tone for a win, the lowest for a loss, so you can tell them apart without looking); cap the length at 320ms; and keep the base tone at or above C3 (131Hz). - Do not write bass notes too low. A phone's small speaker rolls off fast below 200Hz, so C2 (65Hz) comes out as a dull "thud" the player hears as nothing at all rather than as "deep".
- Use the accelerometer, not the gyroscope, for tilt. At rest the accelerometer reads the direction of gravity, which is the tilt, and it does not drift. The gyroscope measures angular velocity; to get a "current angle" from it you have to integrate, and the integral drifts out of usable range within tens of seconds. ⚠ And flip the sign for the on-screen offset — a ball rolls downhill, so lifting the right side makes x larger while the object should slide the other way.
- Lay levels out as a list of rectangles, not a tile map. Collision and drawing share the same rectangle table, so the two cannot disagree; "move first, then push back out of the cell" is simpler than predicting the next step and cannot tunnel through walls.
- Grid movement (Pac-Man / Sokoban / Battle City style). A tick may move any number of pixels, but "turning / stopping / picking up" happens only at the cell centre, so step one pixel at a time. Integer coordinates plus a 1px step necessarily pass exactly through every centre, whatever the speed is.
#definedoes not support backslash line continuation. The lexer reports "unknown character" on the next line. Keep a macro on one line and put the explanation in a separate block comment.- Write your own
num_str()to convert numbers rather than relying onsprintf/printffor floating-point formatting: this platform's formatting path still has gaps (%f/%.3fprint0.000000,%ldprints0, and a dynamic width such as%*dprints literally). Integers, strings, width and radix are all correct. - Do not use
puts/printfas a debugging criterion. In this environmentputscan interleave or repeat its output in programs with many string literals, which makes a standard-I/O problem look like a code problem. Report numbers with the pitch ofui_audio_beep, or show a string in a dialog instead. When "the source looks right but it misbehaves at run time", print the data the program actually read — that beats staring at the source for two rounds. conio/crt/graphicsare for old programs. BASIC's native graphics statements (SCREEN/LINE/CIRCLE/PAINT/PUT) write to fixed DOS addresses and cannot run on a phone; drawing has to go throughui_*. The BGI layer (graphics.h) is a compatibility shim that forwards toui_*, for old programs.
⚠ One more that belongs to real devices only: sound and feel can only be judged on a real phone. The desktop chain can verify that a tone is correct and was emitted, but not clipping in the mix — it does not go through the phone's audio mixing path.
⑦ Where to go next
📘 Look back at the overview
What VML is made of, what the 27 languages are, which old programs run, and which boundaries are deliberately not crossed.
📥 Install it and run
On mobile, install the app and the Files page already holds examples/; on desktop, unzip
and run.
Dolaima 都来码