VML 教学:从第一个程序到小游戏
这一页是循序渐进的:每一步都能照着做,代码可以直接粘贴运行。 还没看过 VML 介绍的话,先扫一眼它讲什么也很快。
① 在哪跑
两个入口,同一套编译器、同一台虚拟机。
手机端(Android / iOS)
- 「命令行」页 —— 直接敲命令:
vml test # 跑内置的自检程序
vml run examples/c/old/old_std_primes.c # 编译并运行
vml make proj.vmk # 按工程文件编译,只出产物、不运行
vml run 按扩展名派发:.vml 走汇编器、.vmb 直接装载字节码,
其余 27 种语言走各自的前端编译器。路径相对命令行页显示的工作目录解析。
- 「文件」页 —— 点开一个文件就有两项操作:
| 文件 | 「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_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;
}
跑它:
# 手机端「命令行」页
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_scr_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, R1 | move @R0, @R1 |
push R12 / pop R15 | push @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 f1 | call f1(写成 call @R0 是间接调用) |
move R0, #42 | move @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 的寄存器,
而寄存器堆下标范围不够,直接越界崩。27 个前端全中。
修法不是"看上下文猜"(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/test/regname.c ——
12 个寄存器形的名字加起来是个定值(831),对不上就说明有变量被当成了寄存器。
手机上跑:vml run examples/c/test/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_get_w(); /* 必须在 ui_win_open 之前问 */
int H = ui_scr_get_h();
ui_win_open("我的窗口", W, H);
ui_2d_clear(0xFF202030); /* 清屏(颜色 0xAARRGGBB,alpha 在前) */
ui_2d_rect(40, 40, 80, 40, 0xFF4ADE80, 1, 0, 4);
ui_2d_present(); /* ⚠ 一帧画完必须调它,否则屏幕上什么都没有 */
while (ui_win_is_closed() == 0) {
if (ui_msg_wait(msg, 0) == 0) continue; /* 没消息就下一轮 */
/* msg[0] = 消息类型,msg[1] 起是参数 */
}
常用接口
| 函数 | 说明 |
|---|---|
ui_scr_get_w() / ui_scr_get_h() | 可用绘图区宽高 —— 必须在 ui_win_open 之前问 |
ui_win_open(title, w, h) | 开窗 |
ui_win_close() / ui_win_is_closed() | 关窗 / 是否已请求关闭 |
ui_2d_clear(color) | 清屏 |
ui_2d_rect(x, y, w, h, color, fill, lw, radius) | 矩形(radius 是圆角半径) |
ui_2d_circle(cx, cy, r, color, fill, lw) | 圆 |
ui_2d_line(x1, y1, x2, y2, color, lw) | 线 |
ui_2d_ellipse(cx, cy, rx, ry, color, fill, lw) | 椭圆 |
ui_2d_text(x, y, s, color, size, anchor) | 文字(锚点 VML_ANCHOR_LEFT / VML_ANCHOR_CENTER) |
ui_2d_present() | 提交这一帧 |
ui_msg_wait(msg, timeout_ms) | 取一条消息(触摸 / 按键 / 定时器同一个队列) |
ui_timer_set(interval_ms, tag) | 重复定时器,到期投一条 VML_MSG_TIMER |
ui_audio_beep(freq, ms) / ui_vibrate_simple(ms, strength) | 音效 / 震动 |
ui_scr_set_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_2d_clear(0xFF202030); /* 背景 */
ui_2d_rect(x, y, TICK, TICK, 0xFF4ADE80, 1, 0, 4);
ui_2d_present(); /* **一帧画完必须调它**,否则屏幕上什么都没有 */
}
int main(void) {
int msg[4];
int t;
W = ui_scr_get_w(); if (W <= 0) W = 360; /* **先问可用绘图区,再开窗** */
H = ui_scr_get_h(); if (H <= 0) H = 620;
ui_win_open("我的游戏", W, H);
ui_scr_set_keep_on(1); /* 玩的时候别熄屏 */
TICK = 24; x = 100; y = 100; vx = 2;
draw_all();
ui_timer_set(40, 0); /* ⚠ **漏了这行游戏一动不动** */
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(); /* 每拍重绘 */
continue;
}
if (t == VML_MSG_KEYDOWN) {
if (msg[1] == VML_KEY_ESCAPE) break;
/* 按键处理 */
}
}
ui_scr_set_keep_on(0);
ui_win_close();
return 0;
}
骨架跑通了,再挑一个现成的游戏对着读 / 改。这些都在 examples/ 里:
| 文件 | 看点 |
|---|---|
Examples/c/app/mario.c | 横版卷轴:摄像机、整数物理、矩形碰撞、踩敌人、金币、终点、三条命 |
Examples/c/app/tetris.c | 网格游戏:形状表、消行、等级加速、最高分存档、暂停 |
Examples/c/app/gomoku.c | 回合制:点棋盘落子、AI 落子、胜负弹框(最干净的模板) |
Examples/c/app/pacman.c | 走格子类的标准做法 + 自带的泛洪验证 |
Examples/c/app/starfall.c | 视觉最丰富:渐变背景、三层视差、粒子爆炸、震屏、触摸拖动操控 |
Examples/cpp/app/gorilla.cpp | 弹道 + 风 + 会塌的楼;音效最完整的一个(八种事件各一种音色与长度) |
Examples/basic/app/gorilla.bas | 同一玩法的 BASIC 版,音效与 C++ 版同一套设计 |
Examples/go/app/snake.go · Examples/swift/app/plane.swift · Examples/csharp/app/racer.cs | 别的语言写的同类游戏,接口与骨架完全一样 |
Examples/fortran/app/sokoban.f90 · Examples/lua/app/life.lua · Examples/rust/app/breakout.rs | 更多语言、同一套 ui_* 接口 |
Examples/c/ui/draw_prims.c | 绘图图元逐个体检(矩形 / 圆 / 多边形 / 路径 / 渐变各一格) |
Examples/c/app/tilt.c | 三个传感器(加速度计 / 陀螺仪 / 融合姿态)的读数画在屏幕上,写倾斜控制照它抄 |
Examples/c/app/piano.c | 多点触控 + 复音音频 |
⚠ 已知有两只暂时跑不起来,不是新问题:Examples/python/app/tetris.py
(Python 前端的列表读可以、写不生效,棋盘放不进去)与 Examples/basic/app/tetris.bas
(BASIC 前端的几处子程序缺陷)。这两个游戏先用 C 版交付。
⑥ 常见坑
下面每一条都是"写完才发现"的,现象与原因一起列出来,方便你对号入座:
| 坑 | 现象 | 原因 |
|---|---|---|
漏 ui_2d_clear() | 画面糊成一团(每帧叠上一帧),十几秒后闪退 | 所有绘制都往同一张图元表追加,只有清屏会清空它 |
漏 ui_timer_set | 窗口出来了,但角色一动不动 | 逻辑都在定时器那一支里,没定时器就只有按键消息 |
漏 ui_2d_present() | 屏幕全空 / 停在上一帧 | 绘制是画进后台的,要提交才上屏 |
| 按屏幕尺寸排版、却开了别的宽高 | 内容右边被切掉 | 窗口宽高就是画布坐标空间;必须先问 ui_scr_get_w/h() 再开窗 |
| 把"长按连发"当前提 | 松手后角色一直往前跑 | KeyUp 会丢(手指滑出按键范围 / 系统吃掉事件)。必须自带刹车:换键即接管 + 连续按住超过 N 拍自动释放 |
ui_scr_set_keep_on(1) 后忘了关 | 退出游戏后屏幕一直不熄 | 退出路径上补 ui_scr_set_keep_on(0) |
| 音效连发一串 | 只听得见最后一个 | ui_audio_beep 是单通道的(后一个音掐掉前一个)⇒ 一个事件配一个音 |
| 网格游戏一拍直接走 N 像素 | 角色卡在半格上:不转向、不吃道具,看着像"按键没反应" | 位置一越过格线,格子编号就报下一格,"前面是墙"随即在离格心半格处把人钉死 |
几条成文的口径
- 音效一个事件一个音:
ui_audio_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_audio_beep的频率报数,或用弹框显示字符串。 「源码看着对、跑起来不对」时,把程序实际读到的数据打出来,比盯着源码推理两轮强。 - 老程序才用
conio/crt/graphics: BASIC 的原生图形语句(SCREEN/LINE/CIRCLE/PAINT/PUT) 写的是固定的 DOS 地址,在手机上跑不了;画图只能走ui_*。 BGI(graphics.h)那一层是转发到ui_*的兼容垫层,供老程序用。
⚠ 还有一条只属于真机的:音效与手感只能在真机上判。 桌面端那条链能验"音对不对、有没有发出来",但验不了混音削波(它不经过手机那条音频混音路径)。
都来码 Dolaima