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.

① 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:
FileWhat "Compile" produces"Run"
A high-level source file (.c / .py / .rs …).vml assemblyCompiles, then runs it
.vml (assembly).vmb bytecode (runs faster)Assembles, then runs it
.vmb (bytecode)— already finalRuns 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, R1move @R0, @R1
push R12 / pop R15push @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 f1call f1 (writing call @R0 is an indirect call)
move R0, #42move @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:

RuleCovers
(a)An operand in a label position (CALL / JMP / conditional jumps / LABEL) is a labelcall f1
(b)If any @-marked register appears in an instruction, its other bare "letters + digits" tokens are labelsmove @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

FunctionNotes
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/:

FileWhat to look at
Examples/c/app/mario.cSide-scrolling platformer: camera, integer physics, rectangle collision, stomping enemies, coins, a goal, three lives
Examples/c/app/tetris.cGrid game: shape tables, line clears, speed-up by level, high-score save, pause
Examples/c/app/gomoku.cTurn-based: tap the board to place, a simple AI, win/lose dialog (the cleanest template)
Examples/c/app/pacman.cThe standard way to do grid movement, plus its own flood-fill verification
Examples/c/app/starfall.cVisually the richest: gradient background, three parallax star layers, particle explosions, screen shake, drag-to-control
Examples/cpp/app/gorilla.cppBallistics + wind + buildings that collapse; the most complete sound design of all (eight events, each with its own pitch and length)
Examples/basic/app/gorilla.basThe 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.csSimilar 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.rsMore languages, the same ui_* interfaces
Examples/c/ui/draw_prims.cA check-up of every drawing primitive (one cell each for rectangle / circle / polygon / path / gradient)
Examples/c/app/tilt.cAll three sensors (accelerometer / gyroscope / fused orientation) drawn on screen; copy it to write tilt controls
Examples/c/app/piano.cMulti-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:

TrapSymptomCause
Forgetting ui_2d_clear()The picture smears (each frame piles on the last), then it crashes after ten seconds or soEvery drawing call appends to the same primitive list; only clearing empties it
Forgetting ui_timer_setThe window appears but nothing movesAll 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 frameDrawing goes into a back buffer and has to be submitted
Laying out for the screen size but opening a different oneThe content is cut off on the rightThe window size is the canvas coordinate space; always call ui_scr_get_w/h() before opening
Assuming key-up always arrivesAfter you let go, the character keeps runningKeyUp 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 itThe screen never sleeps after you quit the gameAdd ui_scr_set_keep_on(0) on the exit path
Firing a run of sound effectsYou only hear the last oneui_audio_beep is single-channel (each new tone cuts off the previous one) ⇒ one sound per event
A grid game moving N pixels per tickThe character gets stuck half a cell short: it will not turn, will not pick things up, and it looks like the keys are deadOnce 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.
  • #define does 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 on sprintf / printf for floating-point formatting: this platform's formatting path still has gaps (%f / %.3f print 0.000000, %ld prints 0, and a dynamic width such as %*d prints literally). Integers, strings, width and radix are all correct.
  • Do not use puts / printf as a debugging criterion. In this environment puts can 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 of ui_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 / graphics are 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 through ui_*. The BGI layer (graphics.h) is a compatibility shim that forwards to ui_*, 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.

VML overview

📥 Install it and run

On mobile, install the app and the Files page already holds examples/; on desktop, unzip and run.

Go to downloads