VML 教学:从第一个程序到小游戏

这一页是循序渐进的:每一步都能照着做,代码可以直接粘贴运行。 还没看过 VML 介绍的话,先扫一眼它讲什么也很快。

① 在哪跑

两个入口,同一套编译器、同一台虚拟机。

手机端(Android / iOS)

  • 「命令行」页 —— 直接敲命令:
vml test              # 跑内置的自检程序
vml run examples/c/old/old_std_primes.c   # 编译并运行
vml make proj.vmk     # 按工程文件编译,只出产物、不运行

vml run 按扩展名派发:.vml 走汇编器、.vmb 直接装载字节码, 其余 22 种语言走各自的前端编译器。路径相对命令行页显示的工作目录解析。

  • 「文件」页 —— 点开一个文件就有两项操作:
文件「VML 编译」产出「VML 运行」
高级语言源文件(.c / .py / .rs …).vml 汇编编译后直接运行
.vml(汇编).vmb 字节码(跑得更快)汇编后直接运行
.vmb(字节码)—— 已经是终态直接运行

程序里的 examples/ 就是随包附带的示例目录,按语言分子目录: examples/c/、examples/basic/、examples/python/ …

桌面端

桌面上用的是仓库里的命令行工具 vmlcli,与手机端同一条编译链, 但快得多 —— 先在电脑上把语法和逻辑验完,手机上只做最后一步。在源码仓库根目录下:

# 先构建一次
dotnet build scripts/vmlcli -c Release

# 编译并运行
dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/old/old_std_primes.c --timeout 20

# 只编译(把汇编写出来,不运行)
dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/old/old_std_primes.c --vml out.vml

⚠ 手机上编写 C 程序要等一分多钟(前端编译 + 汇编 + 链接三万多条指令), 那是正常耗时,别当成卡死。桌面端同一条链只要一两秒。

② 第一个程序

用一份仓库里真实存在的示例:Examples/c/old/old_std_primes.c (手机上的路径是 examples/c/old/old_std_primes.c)。 它是个素数筛 —— 静态大数组、嵌套循环、memset,都是最典型的 C 写法。 下面就是该文件的全文(文件头的注释是中英对照的,这是此目录的惯例):

/* 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_get_language() —— 老程序不该猜系统语言 */
                            /* only for ui_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_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;
}

跑它:

# 手机端「命令行」页
vml run examples/c/old/old_std_primes.c

# 桌面端(仓库根目录)
dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/old/old_std_primes.c --timeout 20

开头是 10000 以内的素数:,然后是每行 10 个、右对齐到 5 列的数字,最后一行是 共 1229 个(10000 以内确实有 1229 个素数,这个数是判据)。

ui_get_language() 那两行是在问界面语言:中文界面打中文、英文界面打英文 —— 老程序不该去猜系统语言。

顺带一提:C 语言与 VML 汇编的编译运行是免费用的,其余语言的编译器在「全能版」里。 所以这一页的第一个例子就是 C。

③ 核心概念

源文件按扩展名识别语言

不用写构建脚本,也不用声明用什么编译器 —— 扩展名就是语言: .c 走 C 前端、.py 走 Python 前端、.rs 走 Rust 前端…… 一共 22 种(另有 .vml 走汇编器、.vmb 直接装载字节码)。 桌面端可以用 --lang <名字> 强制指定前端;-P 会列出所有前端以及它们认的扩展名。

寄存器一律写 @

写 VML 汇编(以及看编译器生成的汇编)时,有一条与其它汇编都不一样的约定: 寄存器一律 @ 打头 —— 内存操作数里的寄存器也一样:

        push @R15
        push @R12
        move @R12 @R13          ; 新写法(生成物一律这样)
        move @R0 [@R12-8]       ; 内存操作数串里的寄存器同样带 @
        call f1                 ; 标签**不加** @
        ret

        push R15                ; 老写法:照旧能读、语义完全相同
        move R0 [R12-8]         ; 老写法:裸名 + 裸内存操作数
老写法(仍可读,等价)新写法(生成物一律用这个)
move R0, R1move @R0, @R1
push R12 / pop R15push @R12 / pop @R15
move R0, [R12-8]move @R0, [@R12-8]
move R0, [R1](寄存器间接)move @R0, [@R1](括号里也带 @)
move R0, [var_x]move @R0, [var_x](标签不加 @)
call f1call f1(写成 call @R0 是间接调用)
move R0, #42move @R0, #42(立即数不加 @)

为什么要有这条 —— 这是真缺陷逼出来的。汇编器判「这是不是寄存器」的办法曾经是 看形状:前缀是 R / F / D / L、 后面接一个能解析成的整数,就算寄存器(而且大小写不敏感)。于是用户自己起的短名字会撞进去:

int f1(void) { return 7; }
int f2(void) { return 11; }
int main(void) { print_int(f1()); print_int(f2()); return 0; }   /* 实测打出 77,应为 7 11 */

call f1 被读成 call R1、call f2 被读成 call R2; 叫 d1 / l1 更狠 —— 它们映射到编号 17 / 25 的寄存器, 而寄存器堆下标范围不够,直接越界崩。22 个前端全中。

修法不是"看上下文猜"(call f1 确实能判,但标签被当值用时 —— move @R0, f1 是取 f1 的地址 —— 与寄存器 F1 在文本上真的分不开), 而是在源头(生成时)把寄存器显式标出来。配套三条规则:

规则覆盖的场景
(a)标签位置(CALL / JMP / 条件跳转 / LABEL)的操作数必是标签call f1
(b)一条指令里出现任一 @ 标记的寄存器 ⇒ 该指令里其余"字母+纯数字"的裸 token 当标签move @R0, f1(取地址)、call @R0(间接调用)
(c)生成时给所有寄存器写 @(含内存操作数串里的:R12-8 → @R12-8)让 (b) 永远能生效

⚠ 一条指令里不要混用两种方言:(b) 说的是"有 @ ⇒ 其余裸 token 是标签", 所以 move [@R1+8] R0 里的 R0 会被当成标签。 要么整条都标(move [@R1+8] @R0),要么整条都不标(move [R1+8] R0)。

名字与寄存器同名时,怎么裁定

一个不带 @ 的寄存器形 token(f1 / d2 / R1 / r100), 归属由这一条裁定:

本文件定义了同名标签 ⇒ 它是标签;否则 ⇒ 它是寄存器;既不是标签又越界 ⇒ 报错。

裁定推迟到整份文件解析完(标签可以前向引用)。也就是说: 变量、函数、形参、数组名都可以叫 f1 / d1 / R2, 它们一律按标签走。仓库里有一份专门钉这件事的样例:Examples/c/regname.c —— 12 个寄存器形的名字加起来是个定值(831),对不上就说明有变量被当成了寄存器。 手机上跑:vml run examples/c/regname.c。

而编译器产出的寄存器引用一律带 @,所以这条代价只落在手写汇编上: 真定义了名叫 R0 的标签时,[R0] 是标签引用,要寄存器间接就写 [@R0]。

实参区与形参位置

VML 的参数传递早就统一成了一种形态:所有参数都走栈,一个都不走寄存器 (前 4 个参数走 R0–R3 的快速路径已经放弃),而且是从右到左压栈。

栈是向下生长的,所以"最后压入的"落在最低地址 —— 由此得到一处永远成立的位置:

  • 第一个实参恒在 [R12+12],之后每格 4 字节:[R12+16]、[R12+20] ……
  • 也就是:第 i 个形参在 [R12+12+4i](i 从 0 起算)。
  • 每格固定 4 字节;8 字节的(double / long)占两格。

而实际上你几乎不需要自己算这个偏移。写 C 的内联汇编时用 ${形参} 占位符, 前端会替你载入:

asm("SYSCALL #6")          /* 旧:靠「第一个形参在 R0」—— 换 ABI 之后就不成立了 */
asm("SYSCALL #6, ${val}")  /* 新:展开成 move R0 [R12+12]; syscall #6 */

⚠ ${} 只认局部变量(形参算局部)。拿全局变量去展开会退化成 R12+0,静默画错。 ⚠ 还有一条:每条 asm() 里的 ${} 都从 R0 开始计数 —— 拆成两条 asm 时,第二个占位符会把第一个覆盖掉。多个占位符要写在同一条 asm 里。

这也解释了为什么"多参数的系统调用"别自己在 C 里手拼:图形、音频、存档那一整层包装 (见下一节)已经按这个约定写好了,直接用包装函数。

④ 绘图与窗口(ui_*)

先记住分层:新写的程序,文字模式用 tty_*、图像模式用 ui_*。 conio / crt / graphics(BGI) 是给老程序补环境的兼容层, 新程序不要直接用。

一帧的结构

调用顺序基本固定:先问可用绘图区 → 再开窗(窗口宽高就是画布的坐标空间)→ 每帧"清屏 + 画 + 提交"→ 在一个消息队列上等输入。

#include <vml_ui.h>

int msg[4];
int W = ui_scr_w();          /* 必须在 ui_win_open 之前问 */
int H = ui_scr_h();
ui_win_open("我的窗口", W, H);

ui_clear(0xFF202030);        /* 清屏(颜色 0xAARRGGBB,alpha 在前) */
ui_rect(40, 40, 80, 40, 0xFF4ADE80, 1, 0, 4);
ui_present();                /* ⚠ 一帧画完必须调它,否则屏幕上什么都没有 */

while (ui_win_closed() == 0) {
    if (ui_wait(msg, 0) == 0) continue;   /* 没消息就下一轮 */
    /* msg[0] = 消息类型,msg[1] 起是参数 */
}

常用接口

函数说明
ui_scr_w() / ui_scr_h()可用绘图区宽高 —— 必须在 ui_win_open 之前问
ui_win_open(title, w, h)开窗
ui_win_close() / ui_win_closed()关窗 / 是否已请求关闭
ui_clear(color)清屏
ui_rect(x, y, w, h, color, fill, lw, radius)矩形(radius 是圆角半径)
ui_circle(cx, cy, r, color, fill, lw)圆
ui_line(x1, y1, x2, y2, color, lw)线
ui_ellipse(cx, cy, rx, ry, color, fill, lw)椭圆
ui_text(x, y, s, color, size, anchor)文字(锚点 VML_ANCHOR_LEFT / VML_ANCHOR_CENTER)
ui_present()提交这一帧
ui_wait(msg, timeout_ms)取一条消息(触摸 / 按键 / 定时器同一个队列)
ui_timer_set(interval_ms, tag)重复定时器,到期投一条 VML_MSG_TIMER
ui_beep(freq, ms) / ui_vibrate(ms, strength)音效 / 震动
ui_keep_on(on)别熄屏
ui_store_set(key, value) / ui_store_get(key, buf, cap)键值存档

颜色一律是 0xAARRGGBB(alpha 在前),0xFFRRGGBB 就是不透明。 文字按 UTF-8 传给宿主,源文件存成 UTF-8 就能直接写中文。

输入:用系统自带的手柄,别自己画

绘图窗口底部本来就有一排屏幕手柄,程序只该收 VML_MSG_KEYDOWN / VML_MSG_KEYUP。 在窗口里自绘一套十字键 + 触摸命中判定的代价是三重的:占掉约 140px 窗口高度、 几何要在"画"与"命中判定"两处各算一遍(改个间距就"看着在键上、点下去没反应")、 而且每个游戏各画一套,用户还得重新学。

按键常量就是 Win32 虚拟键值(接物理键盘是同一套):方向键 37–40、回车 13、 空格 32、A/B/X/Y 就是字母 65/66/88/89、VML_KEY_ESCAPE = 27、 VML_KEY_SELECT = 16。

⑤ 一个小游戏的最小骨架

先写能跑的骨架(开窗 + 定时器 + 消息循环 + 画个方块),跑通了再加玩法。 一次性写完一大坨再调,是这个场景最慢的路。下面这段就是能跑的最小骨架(一个左右弹跳的方块):

#include <vml_ui.h>
#include <stdlib.h>

int W, H, TICK;          /* 画布宽高、格子边长 */
int x, y, vx;            /* 游戏状态放**文件级全局** */

void draw_all(void) {
    ui_clear(0xFF202030);              /* 背景 */
    ui_rect(x, y, TICK, TICK, 0xFF4ADE80, 1, 0, 4);
    ui_present();                      /* **一帧画完必须调它**,否则屏幕上什么都没有 */
}

int main(void) {
    int msg[4];
    int t;

    W = ui_scr_w();  if (W <= 0) W = 360;   /* **先问可用绘图区,再开窗** */
    H = ui_scr_h();  if (H <= 0) H = 620;
    ui_win_open("我的游戏", W, H);
    ui_keep_on(1);                          /* 玩的时候别熄屏 */

    TICK = 24;  x = 100;  y = 100;  vx = 2;
    draw_all();

    ui_timer_set(40, 0);                    /* ⚠ **漏了这行游戏一动不动** */

    while (ui_win_closed() == 0) {
        t = ui_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();                     /* 每拍重绘 */
            continue;
        }
        if (t == VML_MSG_KEYDOWN) {
            if (msg[1] == VML_KEY_ESCAPE) break;
            /* 按键处理 */
        }
    }

    ui_keep_on(0);
    ui_win_close();
    return 0;
}

骨架跑通了,再挑一个现成的游戏对着读 / 改。这些都在 examples/ 里:

文件看点
Examples/c/mario.c横版卷轴:摄像机、整数物理、矩形碰撞、踩敌人、金币、终点、三条命
Examples/c/tetris.c网格游戏:形状表、消行、等级加速、最高分存档、暂停
Examples/c/gomoku.c回合制:点棋盘落子、AI 落子、胜负弹框(最干净的模板)
Examples/c/pacman.c走格子类的标准做法 + 自带的泛洪验证
Examples/c/starfall.c视觉最丰富:渐变背景、三层视差、粒子爆炸、震屏、触摸拖动操控
Examples/cpp/gorilla.cpp弹道 + 风 + 会塌的楼;音效最完整的一个(八种事件各一种音色与长度)
Examples/basic/gorilla.bas同一玩法的 BASIC 版,音效与 C++ 版同一套设计
Examples/go/snake.go · Examples/swift/plane.swift · Examples/csharp/racer.cs别的语言写的同类游戏,接口与骨架完全一样
Examples/fortran/sokoban.f90 · Examples/lua/life.lua · Examples/rust/breakout.rs更多语言、同一套 ui_* 接口
Examples/c/draw_prims.c绘图图元逐个体检(矩形 / 圆 / 多边形 / 路径 / 渐变各一格)
Examples/c/tilt.c三个传感器(加速度计 / 陀螺仪 / 融合姿态)的读数画在屏幕上,写倾斜控制照它抄
Examples/c/piano.c多点触控 + 复音音频

⚠ 已知有两只暂时跑不起来,不是新问题:Examples/python/tetris.py (Python 前端的列表读可以、写不生效,棋盘放不进去)与 Examples/basic/tetris.bas (BASIC 前端的几处子程序缺陷)。这两个游戏先用 C 版交付。

⑥ 常见坑

下面每一条都是"写完才发现"的,现象与原因一起列出来,方便你对号入座:

坑现象原因
漏 ui_clear()画面糊成一团(每帧叠上一帧),十几秒后闪退所有绘制都往同一张图元表追加,只有清屏会清空它
漏 ui_timer_set窗口出来了,但角色一动不动逻辑都在定时器那一支里,没定时器就只有按键消息
漏 ui_present()屏幕全空 / 停在上一帧绘制是画进后台的,要提交才上屏
按屏幕尺寸排版、却开了别的宽高内容右边被切掉窗口宽高就是画布坐标空间;必须先问 ui_scr_w/h() 再开窗
把"长按连发"当前提松手后角色一直往前跑KeyUp 会丢(手指滑出按键范围 / 系统吃掉事件)。必须自带刹车:换键即接管 + 连续按住超过 N 拍自动释放
ui_keep_on(1) 后忘了关退出游戏后屏幕一直不熄退出路径上补 ui_keep_on(0)
音效连发一串只听得见最后一个ui_beep 是单通道的(后一个音掐掉前一个)⇒ 一个事件配一个音
网格游戏一拍直接走 N 像素角色卡在半格上:不转向、不吃道具,看着像"按键没反应"位置一越过格线,格子编号就报下一格,"前面是墙"随即在离格心半格处把人钉死

几条成文的口径

  • 音效一个事件一个音:ui_beep(freq, ms) 单音够用,而且它在结构上不可能削波。 频率与时长的机械规则:取原音型的首音(MIDI 换算成 Hz)、 结局取两端的极值(赢取最高音、输取最低音,不看屏幕也分得出输赢)、 时长封顶 320ms、基音不低于 C3(131Hz)。
  • 低音别写太低:手机外放在 200Hz 以下衰减很快,写 C2(65Hz)出来是"噗"的一声, 玩家听着像没响,而不是"低沉"。
  • 倾斜控制用加速度计,不是陀螺仪:加速度计静止时读到的是重力方向,也就是倾斜本身,不漂; 陀螺仪是角速度,想拿"当前角度"得自己积分,而积分几十秒就漂出可用范围。 ⚠ 而且屏幕偏移要取反号 —— 球往低处滚,把右边抬起来 x 会变大,物体该往低的那边滑。
  • 关卡用矩形列表,别做瓦片地图:碰撞与绘制共用同一份矩形表,不会两处不一致; "先位移、再按格推出"比"预测下一步"简单且不会穿模。
  • 走格子类(吃豆人 / 推箱子 / 坦克大战):一拍走多少像素都行,但"转向 / 停下 / 吃道具" 只在格心做,所以要一像素一判。整数坐标 + 1px 步长必然精确经过格心,与速度无关。
  • #define 不支持反斜杠续行:会让词法器在下一行报"未知字符"。宏一行写完, 多行说明另起一段块注释。
  • 自己写 num_str() 转数字,别依赖 sprintf / printf 的浮点格式化: 本平台的格式化路径还有缺口(%f / %.3f 会打出 0.000000、 %ld 打出 0、%*d 这类动态宽度会打出字面量)。 整数、字符串、宽度、进制都是好的。
  • 调试别拿 puts / printf 当判据:本环境的 puts 在字面量多的程序里输出会串行 / 重复,会把标准输出的毛病看成代码的毛病。 改用 ui_beep 的频率报数,或用弹框显示字符串。 「源码看着对、跑起来不对」时,把程序实际读到的数据打出来,比盯着源码推理两轮强。
  • 老程序才用 conio / crt / graphics: BASIC 的原生图形语句(SCREEN / LINE / CIRCLE / PAINT / PUT) 写的是固定的 DOS 地址,在手机上跑不了;画图只能走 ui_*。 BGI(graphics.h)那一层是转发到 ui_* 的兼容垫层,供老程序用。

⚠ 还有一条只属于真机的:音效与手感只能在真机上判。 桌面端那条链能验"音对不对、有没有发出来",但验不了混音削波(它不经过手机那条音频混音路径)。

⑦ 下一步

📘 回头看介绍

VML 由哪些部件组成、22 门语言都是什么、哪些老程序能跑、哪些是有意不支持的边界。

VML 介绍

📥 装上就能跑

手机端装完 App,点开「文件」页就有 examples/; 桌面端解压即用。

去下载