← 都来码 / Dolaima← 例程索引c/3d/solar3d.c原文

c/3d/solar3d.c · 71512 字节 · 下载原文

/* solar3d.c —— **VML 3D 保留模式的参考实现**:太阳系动态运动演示。
 * solar3d.c — **the reference implementation of VML 3D retained mode**: a solar-system demo.
 *
 * 设计依据 `docs/VML3D接口.md` §四(保留模式)与 §九(太阳系演示 = 验收载体)。
 * Design follows the VML 3D interface design doc, section 4 (retained mode) and section 9 (the solar system is the acceptance vehicle).
 *
 * ## 这个演示要证明的那一件事
 * ## The one thing this demo exists to prove
 *
 * **稳态帧里嘉宾一个几何调用都不发。** 自转与公转都是**声明式**的
 * **In steady-state frames the guest issues zero geometry calls.** Spin and orbit are **declarative**
 * (`ui_3d_obj_set_spin` / `ui_3d_obj_set_orbit`,762/768 行附近的那几行),交付给宿主之后,
 * (`ui_3d_obj_set_spin` / `ui_3d_obj_set_orbit`, set once around lines 762/768 of the original); once handed to the host,
 * 主循环每帧只做两件事:`ui_2d_clear`+`ui_2d_text`(HUD)与 `ui_2d_present()`。
 * the main loop does only two things per frame: `ui_2d_clear`+`ui_2d_text` (HUD) and `ui_2d_present()`.
 * 用 `vmlcli --trace-draw` 抓稳态帧即可断言:`g3_obj_*` / `ui_3d_mesh_create` 出现 **0 次**。
 * Capture a steady-state frame with `vmlcli --trace-draw` to assert it: `g3_obj_*` / `ui_3d_mesh_create` appear **0 times**.
 *
 * ## 跑法
 * ## How to run
 *
 *   桌面(macOS 才有 GL 后端):dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll \
 *                                   third_party/vml/Examples/c/solar3d.c --frames /tmp/f
 *   手机:命令行页敲 `vml run examples/c/solar3d.c`
 *   On a phone: type `vml run examples/c/solar3d.c` in the command-line page.
 *
 * ⚠ **默认只打一行探针摘要**(`GRIDON-DIGEST=… NZ=… N=…`),不是全量网格。
 * **By default only a one-line probe digest is printed** (`GRIDON-DIGEST=... NZ=... N=...`), not the full grid.
 *   要全量网格(判据脚本 `g3-check-solar.sh` 做颜色集合分析用)加 `--arg --grid`。
 *   For the full grid (used by the check script `g3-check-solar.sh` for colour-set analysis) add `--arg --grid`.
 *   原因见 `probe_grid` 上面那段注释:6561 个颜色值打在手机上又慢又把输出冲爆。
 *   See the comment above `probe_grid` for why: 6561 colour values are slow on a phone and flood the output.
 *
 * 操作:`空格` 暂停/继续,`↑`/`↓` 加速/减速,`Esc` 退出。
 * Controls: SPACE pause/resume, UP/DOWN speed, ESC quit.
 *
 * ## ⚠ ABI 约定(照 `docs/VML3D接口.md` §三,用户定的口径)
 * ## ⚠ ABI (per §3 of the design doc)
 *
 * 3D 的**几何量就是 `float`**:`ui_3d_obj_set_pos(obj, 1.5f, 0.0f, 0.0f)` 这样直接写。
 * 3D **geometry values are plain `float`**: write `ui_3d_obj_set_pos(obj, 1.5f, 0.0f, 0.0f)` directly.
 * 宿主侧按 float32 的**位模式**读那个寄存器(`BitConverter.Int32BitsToSingle`,免费)。
 * The host reads the register as a float32 **bit pattern** (`BitConverter.Int32BitsToSingle`, free).
 *
 * ⚠ **别自己把 float 位转换成 int 再传** —— 形参类型是 `float`,传一个 int 会被做**数值转换**,
 * **Do not bit-cast a float to int and pass that** - the parameter is typed `float`, so passing an int applies a **numeric** conversion
 *   位模式当场就毁了(实测症状:宿主一律回 `-2` ErrInvalidParameter)。
 *   and destroys the bit pattern (measured: the host answers `-2` ErrInvalidParameter for everything).
 */
#include <vml_ui.h>
#include <vml_3d.h>   /* 3D 接口库(600–634)—— 包装器在库里,本文件不再自己写 */
                      /* 3D API library (600-634) - the wrappers live in the library, this file does not redefine them. */
#include <math.h>
#include <stdio.h>

/* ── 运行参数(做测量时在 /tmp 的**副本**上改这行,不动工作树 —— 本仓 E126)──────── */
/* -- Run parameters (when measuring, edit this on a **copy** in /tmp; never touch the working tree - repo rule E126) -------- */
#define RUN_FRAMES 0      /* 主循环跑多少帧后退出(0 = 一直跑) */
                          /* Frames to run in the main loop before exiting (0 = run forever) */
#define PROBE_STEP 1        /* 深度对照探针的像素步长(1 = 逐像素,证据最硬) */
                            /* Pixel step of the depth-comparison probe (1 = per-pixel, the hardest evidence) */
#define PROBE_N    81       /* 探针网格边长(PROBE_N × PROBE_N 个像素,覆盖球+环) */
                            /* Probe grid side length (PROBE_N x PROBE_N pixels, covering the sphere and the ring) */

/* ── 场景参数 ────────────────────────────────────────────────────────────── */
/* -- Scene parameters -------------------------------------------------- */
/* ⚠⚠ **场景时钟的口径:1 分钟 = 1 年**(用户 2026-10-04 定)——
 * **The scene clock convention: 1 minute = 1 year** (set by the user on 2026-10-04) -
 * 于是每颗行星的周期就是「**真实公转周期(年)× 60000 毫秒**」,默认速度(1x)下转一圈
 * So each planet's period is "**real orbital period (years) x 60000 ms**"; at the default speed (1x) one revolution
 * 正好等于它在现实里绕太阳一圈的年数:水星 0.24 年 ≈ 14.4 秒、地球 60 秒、
 * takes exactly as many years as it really does: Mercury 0.24 yr ~ 14.4 s, Earth 60 s,
 * 海王星 164.8 年 ≈ 2.7 小时(慢,但那正是真实比例;按 ↑ 能加速)。
 * Neptune 164.8 yr ~ 2.7 hours (slow, but that is the true ratio; UP speeds it up).
 * ⚠ 它和「运动时钟」是**两件事**:行星的**运动**按宿主算好的周期走(墙钟推进),
 * It is **a different thing** from the "motion clock": the planets' **motion** follows the host-computed period (wall-clock driven),
 *   而「过了几年」是**场景时钟**(1 真实分钟 = 1 游戏年)——别把两个并进同一个累加器。
 *   while "how many years have passed" is the **scene clock** (1 real minute = 1 game year) - do not merge the two into one accumulator.
 * 水星(o_p1):0.2408 年 ⇒ 14448ms。反证:把这行改成 0.0f ⇒ 公转停住,判据 1 立刻红。
 * Mercury (o_p1): 0.2408 yr => 14448 ms. Counter-check: change this line to 0.0f => the orbit stops and criterion 1 goes red at once.
 * ⚠ **判据 1 的探针按 `ui_3d_obj_get_screen(o_p1)` 找行星** ⇒ 这个宏必须真的控制 o_p1 的
 * **Criterion 1's probe locates the planet via `ui_3d_obj_get_screen(o_p1)`** => this macro must really drive o_p1's
 *   轨道周期,否则 sed 改了它、画面却纹丝不动,反证就成了一条"不会响的判据"。
 *   orbital period, otherwise a sed edit changes nothing on screen and the counter-check becomes a criterion that never fires. */
#define PLANET1_PERIOD 14448.0f
/* 土星(o_p6):29.457 年 ⇒ 1767420ms */
/* Saturn (o_p6): 29.457 yr => 1767420 ms */
#define SATURN_PERIOD  1767420.0f


/* 把一颗行星的名字画在它旁边。
 * Draws a planet's label next to the planet.
 *
 * ⚠ **这是"读数",不是"每帧算几何"**:行星的位置仍然是**声明**的(`ui_3d_obj_set_orbit`),
 * **This is a readout, not per-frame geometry**: the planet's position is still *declared* (`ui_3d_obj_set_orbit`),
 *   这里只是问宿主"它投影到屏幕哪儿了"(`ui_3d_obj_get_screen` 回 int:低 16 位 = x,高 16 位 = y)。
 *   all we ask the host is "where did it project to" (`ui_3d_obj_get_screen` returns an int: low 16 bits = x, high 16 bits = y).
 *   与判据 2 要防的"嘉宾每帧自己算公转位置"是**两回事** —— 判据 2 的判词也已按这个口径
 *   That is **not** the "guest computes its own orbit every frame" that criterion 2 guards against - that criterion's
 *   收窄成"零几何**提交**"(提交 = 创建/变换/色彩),读数类不计。
 *   wording was narrowed to "zero geometry *submissions*" (submit = create/transform/color); readouts do not count.
 * ⚠ 返回 0 表示**不在视野内**(或句柄无效)—— 那时不画,否则名字会挤在屏幕角落。
 * Returning 0 means **off-screen** (or a bad handle) - draw nothing then, or the names pile up in a screen corner.
 *
 * 用户 2026-10-06:「把星球的文字加描边或者阴影,不然有时候看不到」——
 * User, 2026-10-06: "give the planet labels an outline or a shadow, sometimes you cannot see them" -
 * 淡灰字压到**亮色星球**上时对比度不够(水星灰、金星黄白、木星米黄…),字就没了。
 * light-grey text over a **bright planet** (Mercury grey, Venus yellow-white, Jupiter beige...) loses its contrast and the label vanishes.
 * ⚠ 文字**没有现成的描边能力**:`ui_2d_text_styled` 的 style 位只有 `1=粗 2=斜`,
 * Text has **no built-in outline**: `ui_2d_text_styled`'s style bits are only `1=bold 2=italic`,
 *   没有 outline / shadow 那一档(那是字体本身的属性,不是我们这条宿主文字路的参数)。
 *   with no outline/shadow slot (that is a font property, not a parameter of this host text path).
 *   ⇒ 描边得**自己叠**:先在 8 个 ±1 像素的偏移上各画一遍**纯黑**,最后把正文压在正中间。
 *   => The outline is **stacked by hand**: first draw solid black at each of the 8 one-pixel offsets, then lay the real text on top.
 *   少画正中间那一格是**有意的** —— 那是留给正文的位置,画了等于把正文底下抹黑(笔画细,会糊)。
 *   Skipping the centre cell is **deliberate** - that slot belongs to the real text, and painting it black would smudge these thin strokes.
 * ⚠ 代价是 9 倍文字绘制(9 颗天体 × 9 次)。这里量小(每条 2~3 个汉字、字号 11),
 * The cost is 9x text drawing (9 bodies x 9 draws). The volume here is tiny (2-3 CJK glyphs each, font size 11),
 *   而且**只在视野内**才画(`ui_3d_obj_get_screen` 回 0 就早退),所以可以接受;
 *   and it only happens when **on-screen** (`ui_3d_obj_get_screen` returning 0 exits early), so it is acceptable;
 *   若哪天标签多到几十条,应改成"先画一遍黑字到离屏、再整体压上"的那种批法。
 *   if labels ever reach dozens, switch to drawing the black pass off-screen once and compositing.
 */
void label(int o, char* name) {
    int sc;
    int x; int y;
    int dx; int dy;
    sc = ui_3d_obj_get_screen(o);
    if (sc == 0) return;
    x = sc & 0xFFFF;
    y = (sc >> 16) & 0xFFFF;
    if (x <= 0 || y <= 0) return;
    for (dx = -1; dx <= 1; dx = dx + 1) {
        for (dy = -1; dy <= 1; dy = dy + 1) {
            if (dx == 0 && dy == 0) continue;                        /* 正中间留给正文 / centre is reserved for the text */
                                                                     /* Centre is reserved for the text */
            ui_2d_text(x + 8 + dx, y - 6 + dy, name, C_EDGE, 11, VML_ANCHOR_LEFT);   /* 描边 */
                                                                                     /* Outline pass */
        }
    }
    ui_2d_text(x + 8, y - 6, name, C_LABEL, 11, VML_ANCHOR_LEFT);   /* 淡灰 */
                                                                    /* Light grey label text */
}

/* ⚠ 这里从前有一个 `vcos(x) = sin(x + π/2)` 的**绕过**(注释写着「`cos()` 永远不返回负值」)。
 * ⚠ This used to hold a `vcos(x) = sin(x + PI/2)` **workaround** ("`cos()` never returns a negative value").
 *
 * 2026-10-01 **已撤** —— 它挡的是**两条**缺陷,两条都修好了:
 * Removed on 2026-10-01. It was working around **two** defects, both now fixed:
 *   ① 库的 `cos` 在 `(π/2, π]` 返回 `|cos|`(`scripts/vml-c-probe/cases/51-cos-sign.c` 钉住);
 *   1) the library's `cos` returned `|cos|` on `(pi/2, pi]` (pinned by `scripts/vml-c-probe/cases/51-cos-sign.c`);
 *   ② C 前端把 `float <op> float` 的结果类型推成 `Float`(值其实在 `R0`)⇒ `if (cos(x) < 0)` 判不出来
 *   2) the C front end typed `float <op> float` as `Float` (the value is actually in `R0`) => `if (cos(x) < 0)` could not be decided
 *      (`cases/52-float-cmp-value.c` 钉住)。
 *      (pinned by `cases/52-float-cmp-value.c`).
 * ⇒ 直接写 `cos(x)` 即可。撤掉后本程序的两个探针哈希**逐字节不变**(`43950DAF`/`5DC54B5C`)。
 * => Just write `cos(x)` now. After removing it, this program's two probe hashes are **byte-for-byte unchanged** (`43950DAF`/`5DC54B5C`).
 *
 * ⚠ **撤的时候别用"就地改名"**:把 `float vcos` 改叫 `float cos` 会在程序里多出一个**本地
 * **Do not just rename it in place** when removing it: renaming `float vcos` to `float cos` adds a **local**
 *   `float cos(float)` 定义**,按链接器"后链的赢"(见 `docs/开发教训.md` E132)把库里的 `cos` 顶掉,
 *   `float cos(float)` definition that, by the linker's "last one linked wins" rule (see the dev-lessons doc, E132), shadows the library `cos`,
 *   哈希当场变成 `D9AFFCF1`/`2FFB8FC7`。正解是**删掉定义 + 改调用点**。
 *   and the hash immediately becomes `D9AFFCF1`/`2FFB8FC7`. The fix is to delete the definition and change the call sites. */

/* ══ 网格装配(写进全局 MB,再整块交给 ui_3d_mesh_create —— 一次 syscall 传完整网格)══
 * Mesh assembly (build into the global MB, then hand the whole block to ui_3d_mesh_create - one syscall passes the full mesh)
 * 顶点 = 8 个 float32(pos3 + nrm3 + uv2,32 字节),紧随其后是 int32 索引。
 * A vertex = 8 float32 (pos3 + nrm3 + uv2, 32 bytes), followed immediately by int32 indices.
 * ⚠ 顶点与索引**在同一块内存里**,索引用 int 指针写 —— 这是位模式搬运,不是数值转换。
 * Vertices and indices live **in the same block of memory**; indices are written through an int pointer - that is bit-pattern movement, not numeric conversion. */
float MB[4400];
int g_vn;
int g_in;

void mb_vertex(int i, float px, float py, float pz, float nx, float ny, float nz) {
    int b = i * 8;
    MB[b + 0] = px; MB[b + 1] = py; MB[b + 2] = pz;
    MB[b + 3] = nx; MB[b + 4] = ny; MB[b + 5] = nz;
    MB[b + 6] = 0.0f; MB[b + 7] = 0.0f;
}

void mb_index(int k, int v) {
    int* ip;
    ip = (int*)&MB[0];
    ip[g_vn * 8 + k] = v;
}

int mb_ptr(void) { return (int)&MB[0]; }

/* 单位球(半径 r):法线就是归一化后的位置 —— 球面法线不用另算。 */
/* Unit sphere (radius r): the normal is just the normalised position - no separate sphere-normal maths. */
void gen_sphere(int rings, int segs, float r) {
    int i; int j; int k; int v;
    v = 0;
    for (i = 0; i <= rings; i = i + 1) {
        float phi = 3.14159265f * i / rings;
        float sp = sin(phi);
        float cp = cos(phi);
        for (j = 0; j <= segs; j = j + 1) {
            float th = 6.28318531f * j / segs;
            float nx = sp * cos(th);
            float ny = cp;
            float nz = sp * sin(th);
            mb_vertex(v, nx * r, ny * r, nz * r, nx, ny, nz);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (i = 0; i < rings; i = i + 1) {
        for (j = 0; j < segs; j = j + 1) {
            int a = i * (segs + 1) + j;
            int b = a + segs + 1;
            mb_index(k, a);     k = k + 1;
            mb_index(k, b);     k = k + 1;
            mb_index(k, a + 1); k = k + 1;
            mb_index(k, a + 1); k = k + 1;
            mb_index(k, b);     k = k + 1;
            mb_index(k, b + 1); k = k + 1;
        }
    }
    g_in = k;
}

/* 土星环:XZ 平面上的一圈环带(法线 +Y),内外半径以**行星半径**为单位。
 * Saturn ring: a flat annulus in the XZ plane (normal +Y); inner and outer radii are in **planet radii**.
 * ⚠ 刻意**不做背面剔除**(后端也没开):平面环带一旦剔除,从一半角度看过去会整片消失,
 * Back-face culling is **deliberately off** (the backend has it off too): cull a flat annulus and it vanishes entirely from half the angles,
 *   而那种故障只在特定视角出现 —— 最难查的一类。
 *   and that kind of bug only shows at certain view angles - the hardest kind to track down. */
void gen_ring(int segs, float inner, float outer) {
    int j; int k; int v;
    v = 0;
    for (j = 0; j <= segs; j = j + 1) {
        float th = 6.28318531f * j / segs;
        float c = cos(th);
        float s = sin(th);
        mb_vertex(v, c * inner, 0.0f, s * inner, 0.0f, 1.0f, 0.0f); v = v + 1;
        mb_vertex(v, c * outer, 0.0f, s * outer, 0.0f, 1.0f, 0.0f); v = v + 1;
    }
    g_vn = v;
    k = 0;
    for (j = 0; j < segs; j = j + 1) {
        int a = j * 2;
        mb_index(k, a);     k = k + 1;
        mb_index(k, a + 1); k = k + 1;
        mb_index(k, a + 2); k = k + 1;
        mb_index(k, a + 1); k = k + 1;
        mb_index(k, a + 3); k = k + 1;
        mb_index(k, a + 2); k = k + 1;
    }
    g_in = k;
}

/* 星空:一堆小三角,撒在一个大球壳上。位置由一个整数伪随机序列定(确定性 ⇒ 可复现)。
 * Starfield: a pile of small triangles scattered on a large spherical shell. Positions come from an integer pseudo-random sequence (deterministic => reproducible).
 * 小三角是**双面**的(后端不剔除背面 + 双面光照),所以朝向随便。
 * The small triangles are **double-sided** (no back-face culling + two-sided lighting), so their orientation is arbitrary. */
void gen_stars(int n, float radius, float size) {
    int i; int k; int v;
    v = 0;
    for (i = 0; i < n; i = i + 1) {
        int r1 = (i * 7919 + 13) % 1000;
        int r2 = (i * 6271 + 101) % 1000;
        float th = 6.28318531f * r1 / 1000.0f;
        float ph = 3.14159265f * r2 / 1000.0f;
        float ux = sin(ph) * cos(th);
        float uy = cos(ph);
        float uz = sin(ph) * sin(th);
        float cx = ux * radius;
        float cy = uy * radius;
        float cz = uz * radius;
        /* 小三角:正对着观察者的方向随便(双面),只要不退化成一个点 */
        /* Small triangle: the orientation is arbitrary (double-sided), as long as it does not degenerate to a point */
        mb_vertex(v, cx - size, cy - size, cz, 0.0f, 0.0f, 1.0f); v = v + 1;
        mb_vertex(v, cx + size, cy - size, cz, 0.0f, 0.0f, 1.0f); v = v + 1;
        mb_vertex(v, cx, cy + size, cz, 0.0f, 0.0f, 1.0f); v = v + 1;
    }
    g_vn = v;
    k = 0;
    for (i = 0; i < n; i = i + 1) {
        mb_index(k, i * 3 + 0); k = k + 1;
        mb_index(k, i * 3 + 1); k = k + 1;
        mb_index(k, i * 3 + 2); k = k + 1;
    }
    g_in = k;
}

/* ══ 深度对照探针(判据 3)══════════════════════════════════════════════════
 * Depth-comparison probe (criterion 3)
 * 同一个视口、同一个相机、同一份场景,只翻 `ui_3d_viewport_set_depth` 这一位,读同一片网格的像素。
 * Same viewport, same camera, same scene - flip only the `ui_3d_viewport_set_depth` bit and read the same patch of pixels.
 * 正面:两幅**必须不同**(远端的环被球体遮住了,关掉深度它就盖上来)。
 * Positive case: the two images **must differ** (the distant ring is hidden by the sphere; with depth off it covers it).
 * 反证:宿主用环境变量 `WAYCODER_VML3D_NODEPTH=1` 把深度全局关掉 ⇒ 两幅一模一样 ⇒ 判据红。
 * Counter-check: the host turns depth off globally via `WAYCODER_VML3D_NODEPTH=1` => the two images are identical => the criterion goes red.
 * ⚠ 这条对**画序**有要求:球必须**先建**(近的先画),环后建。渲染器如实按声明顺序提交、
 * This requires a specific **draw order**: the sphere must be created **first** (near drawn first), the ring after.
 *   不做远近排序 —— 否则关掉深度后结果不变,判据永远响不了(§十 记的正是这条教训)。
 *   The renderer submits strictly in declaration order and never sorts by distance - otherwise turning depth off changes nothing and the criterion can never fire (that is exactly the lesson recorded in section 10). */
/* `--arg --grid` 时打**全量网格**(判据脚本 `g3-check-solar.sh` 要靠它做颜色集合分析);
 * With `--arg --grid`, print the **full grid** (the check script `g3-check-solar.sh` needs it for colour-set analysis);
 * 默认只打一行**摘要**。为什么默认要收起来(用户 2026-10-01 真机实测):
 * by default only a one-line **digest** is printed. Why it is hidden by default (measured on a real device by the user on 2026-10-01):
 *   「**不要输出这么多颜色,会卡慢**」—— 81×81 = **6561 个**十六进制值打进行内 Label,
 *   "do not print that many colours, it gets slow" - 81x81 = **6561** hex values printed into an inline Label,
 *   重排很慢;而且命令行页的缓冲只有 256 行,整屏被这一串冲掉,**端到端验收反而取不到输出**
 *   is slow to relayout; and the command-line page's buffer is only 256 lines, so the flood wipes the screen and **end-to-end acceptance ends up with no output at all**
 *   (实测:`ok=True` 但增量输出为空)。判据本身一条没减 —— `--grid` 那条路原样保留,
 *   (measured: `ok=True` but the incremental output was empty). No criterion was dropped - the `--grid` path is kept as is,
 *   摘要里的 32 位 FNV-1a 哈希对"两幅图是否逐字节相同"与全量网格**等价**。
 *   and the 32-bit FNV-1a hash in the digest is **equivalent** to the full grid for "are the two images byte-for-byte identical". */
int dump_full = 0;

void probe_grid(int vp, int sc, int w, int h, char* tag) {
    int cxx = (sc & 0xFFFF) - PROBE_STEP * (PROBE_N / 2);
    int cyy = ((sc >> 16) & 0xFFFF) - PROBE_STEP * (PROBE_N / 2);
    int i; int j;
    int hh; int nz;
    hh = 2166136261;          /* FNV-1a 32 位 */
                              /* FNV-1a 32-bit */
    nz = 0;
    if (dump_full) printf("%s=", tag);
    for (i = 0; i < PROBE_N; i = i + 1) {
        for (j = 0; j < PROBE_N; j = j + 1) {
            int px = cxx + j * PROBE_STEP;
            int py = cyy + i * PROBE_STEP;
            int c;
            if (px < 0) px = 0;
            if (py < 0) py = 0;
            if (px >= w) px = w - 1;
            if (py >= h) py = h - 1;
            c = ui_3d_viewport_get_pixel(vp, px, py) & 0xFFFFFF;
            if (dump_full) {
                printf("%06X ", c);
            } else {
                hh = (hh ^ (c & 0xFF)) * 16777619;
                hh = (hh ^ ((c >> 8) & 0xFF)) * 16777619;
                hh = (hh ^ ((c >> 16) & 0xFF)) * 16777619;
                if (c != 0) nz = nz + 1;
            }
        }
    }
    if (dump_full) {
        printf("\n");
    } else {
        /* ⚠ 分两半打(各 16 位)而不是 `%08X`:这片打印实现下 `%X` 对负数的表现没把握,
         * Print it in two halves (16 bits each) instead of `%08X`: with this print implementation the behaviour of `%X` on negatives is uncertain,
         *   掩到 0..65535 就与符号无关了。标签是 `-DIGEST` 而不是 `=`,**故意让判据脚本
         *   and masking to 0..65535 makes it sign-independent. The tag is `-DIGEST` rather than `=`, deliberately so the check script's
         *   的 `grep '^GRIDON='` 找不到** —— 那样脚本会报"没拿到 GRID 行(判据作废)",
         *   `grep '^GRIDON='` finds nothing - the script then reports "no GRID line (criterion void)", which is a **loud failure**;
         *   是**响亮地失败**;若沿用 `GRIDON=` 它会把摘要当网格解析,静默算出一堆垃圾。
         *   had we kept `GRIDON=` it would parse the digest as a grid and silently compute rubbish. */
        printf("%s-DIGEST=%04X%04X NZ=%d N=%d\n",
               tag, (hh >> 16) & 0xFFFF, hh & 0xFFFF, nz, PROBE_N * PROBE_N);
    }
}

/* ══ 颜色 ══════════════════════════════════════════════════════════════════ */
/* == Colours == */

/* ⚠ 颜色一律用 `ui_color_rgb(r, g, b)` **写出来**,不要写十进制负数。
 * Colours are always spelled with `ui_color_rgb(r, g, b)`, never as negative decimals.
 *   用户 2026-10-06:「颜色为什么不使用 `ui_color_rgb(r,g,b)` 这样不是更直观吗?使用负数更难懂什么颜色」
 *   User, 2026-10-06: "why not write colours with `ui_color_rgb(r,g,b)`, is that not more intuitive? Negative numbers make it harder to tell what colour it is"
 *   —— 说得对,而它**本来就在库里**(`Lib/c/vml_ui.h`,把 r/g/b 打包成 ui_* 要的 0xAARRGGBB、
 *   - correct, and the helper **already existed in the library** (`Lib/c/vml_ui.h` packs r/g/b into the 0xAARRGGBB ui_* wants
 *   顺便夹到 0..255),从前这一整个文件却在写 `-16250870` 再拿注释补一句 `hex 0xFF080A0A 写在旁边`
 *   and clamps to 0..255); this whole file used to write `-16250870` and then patch it with a comment saying "hex 0xFF080A0A" next to it,
 *   —— 需要注释补救,本身就是可读性不够的证据(本仓头号重复形态:助手已存在但被绕过)。
 *   - having to be patched by a comment is itself evidence it was not readable enough (the repo's number-one recurring pattern: the helper already exists but is bypassed).
 *
 * ⚠ 但**每帧都要用的那几个,要在初始化时算一次存起来**:`ui_color_rgb` 是一次**系统调用**
 * But the **per-frame** colours are computed **once** during init and kept in variables: `ui_color_rgb` is a **syscall**
 *   (VM ↔ 宿主一次往返),直接写在每帧的绘制调用里就是每帧多几次往返。初始化时调用的那些
 *   (one VM <-> host round trip), so inlining it into per-frame draw calls would add round trips every frame. The init-time ones
 *   (`ui_3d_obj_set_color` 一律只调一次)直接内联即可,不必为它们再立变量。
 *   (`ui_3d_obj_set_color`, all called exactly once) are inlined and need no variables of their own.
 */
int C_WHITE;    /* 0xFFFFFFFF 白   —— 标题 / 星星 */
                /* 0xFFFFFFFF white - title / stars */
int C_HUD;      /* 0xFF808080 灰   —— 底部提示行 */
                /* 0xFF808080 grey - bottom hint line */
int C_LABEL;    /* 0xFFAAAAAA 淡灰 —— 星球名 */
                /* 0xFFAAAAAA light grey - planet names */
int C_EDGE;     /* 0xFF000000 纯黑 —— 星球名的描边 */
                /* 0xFF000000 solid black - planet-name outline */
int C_WARN;     /* 0xFFFFFF00 黄   —— 初始化提示 / 暂停 */
                /* 0xFFFFFF00 yellow - init notice / paused */

/* ══ 主程序 ══════════════════════════════════════════════════════════════ */
/* == Main program == */

int main(void) {
    int vp; int sw; int sh;
    int m_sphere; int m_ring; int m_stars; int m_orbit;
    /* 每帧要用的文字色:算一次(各一次系统调用),下面所有绘制都复用 */
    /* Per-frame text colours: computed once (one syscall each) and reused by every draw below */
    C_WHITE = ui_color_rgb(255, 255, 255);
    C_HUD   = ui_color_rgb(128, 128, 128);
    C_LABEL = ui_color_rgb(170, 170, 170);
    C_EDGE  = ui_color_rgb(0, 0, 0);
    C_WARN  = ui_color_rgb(255, 255, 0);
    /* ── 参数:`--grid` ⇒ 打全量探针网格(判据脚本用);默认只打摘要(见 probe_grid 的注释)。
     * -- Arguments: `--grid` => print the full probe grid (used by the check script); by default only the digest (see probe_grid).
     *    走 `vmlcli … --arg --grid`,guest 这边由 `ui_arg_get` 读(宿主 syscall 62/63,实测可用)。
     *    Run as `vmlcli ... --arg --grid`; the guest reads it with `ui_arg_get` (host syscalls 62/63, measured working). */
    {
        char abuf[64];
        int ai; int an;
        an = ui_arg_get_count();
        for (ai = 1; ai < an; ai = ai + 1) {
            abuf[0] = 0;
            ui_arg_get(ai, abuf, 64);
            /* 逐字符比到结尾符(不引 strlen —— 这片运行时的字符串库是 ustring.vml,
             * Compare character by character to the terminator (no strlen - this runtime's string library is ustring.vml;
             * 有没有同名导出得先去查,这里不需要那个依赖)。
             * whether it exports the same name would have to be checked first, and this does not need that dependency). */
            if (abuf[0] == '-' && abuf[1] == '-'
                && abuf[2] == 'g' && abuf[3] == 'r'
                && abuf[4] == 'i' && abuf[5] == 'd' && abuf[6] == 0) {
                dump_full = 1;
            }
        }
    }
    int o_stars; int o_sun; int o_ring; int o_moon; int o_sat;
    int o_p1; int o_p2; int o_p3; int o_p4; int o_p5; int o_p6; int o_p7; int o_p8;
    int o_o1; int o_o2; int o_o3; int o_o4; int o_o5; int o_o6; int o_o7; int o_o8;
    int sc0; int sc1;
    int frame; int paused; int speed;
    /* 场景时钟:**1 分钟 = 1 年**(用户 2026-10-04 定)。它**独立于运动时钟** ——
     * Scene clock: **1 minute = 1 year** (set by the user on 2026-10-04). It is **independent of the motion clock** -
     * 行星公转由宿主按「周期 = 真实年数 × 60000ms」算角度;这里只是用**同一个真实时间
     * the host computes the planets' orbit angle from "period = real years x 60000 ms"; here we just use the **same real time
     * 乘同一个 speed** 累出「过了几年」,供 HUD 显示,两者不会漂。
     * times the same speed** to accumulate "years elapsed" for the HUD, so the two never drift apart. */
    int sceneMs; int clockMs;
    int dragging; int lastTx; int lastTy; int touch[3];
    float camYaw; float camPitch;   /* 相机绕太阳的方位角/俯仰角(度) */
                                    /* Camera azimuth / pitch around the sun (degrees) */
    float camZoom;                  /* 距离倍率(两指开合)。1 = 原样 */
                                    /* Distance multiplier (two-finger pinch). 1 = unchanged */
    int t0[3]; int t1[3];           /* 第 0 / 1 根手指 */
                                    /* Finger 0 / finger 1 */
    int pinching; float lastPinch;
    float planett;

    /* ⚠ **开窗传 `0, 0`(= 尺寸交给宿主),开窗之后再问可用绘图区** —— 这样拿到的才是**真实画布**,
     * ⚠ **Open with `0, 0` (let the host size it), then ask for the usable drawing area afterwards** - that is what returns the **real canvas**,
     * 画布的坐标空间与视口**逐像素重合**,注视点(= 坐标原点 = 太阳)才落在**窗口正中心**。
     * so the canvas coordinate space **coincides pixel for pixel** with the viewport and the look-at point (= origin = the sun) lands in the **exact centre of the window**.
     * (**开窗之前**问到的只是估算,手机上会与真实画布差一截。)
     * (What you get **before** the open is only an estimate and differs from the real canvas on a phone.)
     *
     * 原先写死 640x640,而手机/平板的可用绘图区是 393x580(竖屏)—— 视口贴在画布
     * It used to be hard-coded to 640x640, while a phone/tablet's usable area is 393x580 (portrait) - the viewport stuck to the canvas's
     * **左上角**,整个画面偏左上,一眼就看得出「太阳不在窗口中心」(用户实测报的)。
     * **top-left corner**, the whole image shifted up-left, and it was obvious at a glance that "the sun is not in the window centre" (reported by the user on a real device).
     * 这条也是 `Examples/README.md` 里写死的约定:「窗口宽高就是画布的坐标空间」。
     * This is also the convention fixed in `Examples/README.md`: "the window dimensions are the canvas coordinate space". */
    /* ⚠ 问尺寸用 **_ex** 并显式声明 chrome:`ui_scr_*_ex(方向, VML_CHROME_NONE)` = 「**不要任何
     * ⚠ Ask with **_ex** and declare the chrome: `ui_scr_*_ex(orientation, VML_CHROME_NONE)` = "how big it can be with
     * chrome 时**能有多大」—— 与下面 `ui_win_open_ex(..., gamepad=0)` 声明的配置对上;
     * **no chrome at all**" -- matching the configuration declared by `ui_win_open_ex(..., gamepad=0)` below;
     * **开窗之后**问到的就是这个配置下的**真实画布**。
     * asked **after** the open, this is the **real canvas** for that configuration.
     * ⚠ 这条从前是写在开窗**之前**的:那时 `ui_scr_get_w()` 只会按**默认** chrome(要手柄)估算,
     * ⚠ This used to be written **before** the open: `ui_scr_get_w()` then only estimated for the **default**
     * 问到的比实际画布**矮 152dp**、视口贴左上角、注视点偏出中心(用户实测报的「太阳不在窗口的中心」)。
     * chrome (with gamepad), so the answer was **152dp shorter** than the real canvas, the viewport stuck to the
     * top-left and the look-at point drifted off centre (the user-reported "sun is not in the window centre"). */
    /* 用 `_ex` 而不是 `ui_win_open`:**第 5 个参数 gamepad=0 明确声明本程序不收手柄**。
     * Uses `_ex` rather than `ui_win_open`: **the 5th parameter gamepad=0 explicitly declares this program takes no gamepad**.
     * 本程序是**全触摸**的(拖动旋转),手柄区纯属白占地方 —— 去掉它可绘图区还会变大,
     * This program is **all touch** (drag to rotate); the gamepad area is pure wasted space - removing it also grows the drawing area,
     * 而画布与视口仍逐像素重合(第 4 个参数 rotatable=1,与老号 ui_win_open 的默认一致)。
     * while the canvas and viewport still coincide pixel for pixel (4th parameter rotatable=1, same as the old ui_win_open default). */
    ui_win_open_ex("太阳系 Solar System", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    sw = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    sh = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    if (sw <= 0) sw = 640;
    if (sh <= 0) sh = 640;
    printf("SCR=%dx%d\n", sw, sh);

    vp = ui_3d_viewport_create(0, 0, sw, sh, 0);
    printf("VP=%d\n", vp);
    if (vp <= 0) {
        /* 拿不到 3D 设备(后端缺失 / 驱动不给上下文)—— 如实报,不装作画出来了 */
        /* No 3D device (backend missing / driver refused a context) - report it honestly, do not pretend we drew anything */
        printf("NO_3D_DEVICE=1\n");
        return 0;
    }

    /* 相机:固定俯视(眼睛在斜上方,注视原点)。up 固定 +Y(§四 没给 up)。 */
    /* Camera: a fixed overhead-ish view (eye up and to the side, looking at the origin). up is fixed +Y (section 4 gave no up). */
    ui_3d_viewport_set_camera(vp, 0.0f, 620.0f, 560.0f,
                  0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp, 60.0f, 1.0f, 8000.0f);
    /* **按宽高比自动取景**:只声明"这个系有多大"(最外轨道 310 + 土星半径 46 ≈ 356,留点余量)。
     * **Aspect-ratio auto-framing**: we only declare "how big this system is" (outer orbit 310 + Saturn radius 46 ~ 356, with a little margin).
     * 宿主会按当前视口的宽高比算该退多远 ⇒ 竖屏手机与宽屏桌面各自框得正好,
     * The host works out how far to pull back for the current viewport aspect => portrait phones and wide desktops each frame it just right,
     * 而**轨道半径/周期/相位一个字没动**(那些才是"轨迹")。
     * while **not one orbit radius/period/phase was touched** (those are the actual "trajectory"). */
    ui_3d_viewport_fit(vp, 460.0f);
    ui_3d_viewport_set_bg(vp, ui_color_rgb(8, 8, 10));          /* 深空 */
                                                                /* Deep space */
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, -1, 0.50f);

    /* ── 网格 ── */
    /* -- Meshes -- */
    gen_sphere(12, 18, 1.0f);
    m_sphere = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_ring(64, 1.45f, 2.25f);
    m_ring = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_stars(160, 1500.0f, 7.0f);
    m_stars = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    /* 轨道线:**单位细环**(内 0.995 / 外 1.005)—— 每条轨道用一个 obj 缩放到该半径。
     * Orbit line: a **unit thin ring** (inner 0.995 / outer 1.005) - one obj per orbit scaled to that radius.
     * 省掉"每颗行星各生成一份轨道环"的 8 份重复网格,而线宽在缩放后仍只有约 1% 半径,
     * This avoids 8 duplicate "one ring mesh per planet"; after scaling the line width is still only ~1% of the radius,
     * 又细又不闪。⚠ 它与土星环共用 `gen_ring`(同一个生成器、同一处口径)。
     * thin and without shimmering. It shares `gen_ring` with the Saturn ring (one generator, one convention). */
    gen_ring(72, 0.995f, 1.005f);
    m_orbit = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    printf("MESH=%d,%d,%d,%d\n", m_sphere, m_ring, m_stars, m_orbit);
    if (m_sphere <= 0 || m_ring <= 0 || m_stars <= 0 || m_orbit <= 0) { printf("NO_MESH=1\n"); return 0; }

    /* ── 物体 ────────────────────────────────────────────────────────────────
     * -- Objects --
     * ⚠ **创建顺序 = 绘制顺序**,且刻意让**土星的球先于它的环** —— 判据 3 的反证靠这个:
     * **Creation order = draw order**, and Saturn's sphere is deliberately created **before its ring** - criterion 3's counter-check relies on it:
     *   关掉深度测试时,后画的环必须能盖住先画的球。
     *   with the depth test off, the later-drawn ring must cover the earlier-drawn sphere.
     *
     * ## 8 大行星:真实数据 → 屏幕上的**压缩**(每一条都是为了让 8 颗都看得见)
     * ## The 8 planets: real data -> on-screen **compression** (each line exists so all 8 stay visible)
     *   轨道半径  R = 62 + 56*sqrt(a_AU)      —— 开方压缩。照线性画的话海王星(30AU)与
     *   Orbit radius   R = 62 + 56*sqrt(a_AU)      - square-root compression. Drawn linearly, Neptune (30AU) and
     *                                            水星(0.39AU)是 **77:1**,水星会缩进太阳里。
     *                                                                                       Mercury (0.39AU) would be **77:1** and Mercury would shrink into the sun.
     *   行星半径  r = 3.0 + 2.6*sqrt(r/6371)  —— 同样开方。木星真实半径是地球的 11 倍,
     *   Planet radius  r = 3.0 + 2.6*sqrt(r/6371)  - also square root. Jupiter's real radius is 11x Earth's,
     *                                            照线性画水星只剩 0.4 个单位(不到一个像素)。
     *                                                                                       drawn linearly Mercury would be only 0.4 units (under one pixel).
     *   公转周期  T = 6000*sqrt(a) 毫秒       —— 开普勒是 1.5 次方,那会让海王星 165 年 = 7 分钟。
     *   Orbit period   T = 6000*sqrt(a) ms         - Kepler is a 1.5 power, which would make Neptune 165 yr = 7 minutes.
     *                                            开方压成 3.7 秒 ~ 33 秒,8 颗都在动。
     *                                                                                       square root compresses it to 3.7 s - 33 s; all 8 keep moving.
     * ⚠ **压缩只动"屏幕上多大 / 多远 / 多快",不动拓扑**:谁绕谁、环在谁身上、
     * **The compression only touches "how big / how far / how fast on screen", never the topology**: who orbits whom, whose ring it is,
     *   月亮是地球的子物体 —— 这些是"真值",一个都不能错(判据 2 靠的正是"运动是声明的")。
     *   the moon being a child of Earth - these are "true values" that must all be right (criterion 2 relies on "motion is declared").
     */
    o_stars = ui_3d_obj_create(vp, m_stars);
    ui_3d_obj_set_color(o_stars, ui_color_rgb(255, 255, 255));

    o_sun = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_sun, ui_color_rgb(255, 248, 240));                    /* 0xFFFFF8F0 太阳:接近纯白偏暖,受光后仍是最亮天体 */
                                                                                /* 0xFFFFF8F0 sun: near-white with a warm tint; still the brightest body after lighting */
    ui_3d_obj_set_scale(o_sun, 28.0f);                /* ⚠ 有意放大:真实比例下太阳在 fit 框里只剩几个像素 */
                                                      /* Deliberately enlarged: at true scale the sun would be only a few pixels inside the fit frame */
    ui_3d_obj_set_spin(o_sun, 0.0f, 4.0f, 0.0f);
    /* ⚠ **太阳是自发光的天体**:受光模型下它也是个"被照亮的球"(有昼夜明暗、亮度随光方向变),
     * **The sun is a self-luminous body**: under the lit model it is just "a lit sphere" (with day/night shading and brightness that follows the light direction),
     * 那与"恒星"这件事不符。`ui_3d_obj_set_emissive` 让它的片元**直接输出基色** ——
     * which contradicts its being a star. `ui_3d_obj_set_emissive` makes its fragment **output the base colour directly** -
     * 亮度恒定、没有明暗交界,这才是"发光"(用户口径:「太阳是发光的星球」)。
     * constant brightness with no terminator, which is what "glowing" means (the user's wording: "the sun is a glowing planet").
     * ⚠ 别用"把环境光调到 1"来凑:那会让**所有**行星一起失去立体感(变成一盘平色)。
     * Do not fake it by setting ambient to 1: that would flatten **every** planet into a sheet of flat colour. */
    ui_3d_obj_set_emissive(o_sun, 1);

    /* 水星 Mercury:0.39AU / 半径 2440km */
    /* Mercury: 0.39AU / radius 2440km */
    o_p1 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p1, ui_color_rgb(176, 168, 154));
    ui_3d_obj_set_scale(o_p1, 10f);
    ui_3d_obj_set_rot(o_p1, 0.0f, 0.0f, 0.0f);
    ui_3d_obj_set_spin(o_p1, 0.0f, 60f, 0.0f);
    ui_3d_obj_set_orbit(o_p1, 0.0f, 0.0f, 0.0f, 90f, PLANET1_PERIOD, 0f);
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o1 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o1, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o1, 90f);

    /* 金星 Venus:0.72AU / 半径 6052km */
    /* Venus: 0.72AU / radius 6052km */
    o_p2 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p2, ui_color_rgb(232, 200, 122));
    ui_3d_obj_set_scale(o_p2, 11f);
    ui_3d_obj_set_rot(o_p2, 0.0f, 0.0f, 177.4f);
    ui_3d_obj_set_spin(o_p2, 0.0f, 47f, 0.0f);
    ui_3d_obj_set_orbit(o_p2, 0.0f, 0.0f, 0.0f, 115f, 36912.0f, 47f);   /* 金星 0.6152 年 */
                                                                        /* Venus 0.6152 yr */
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o2 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o2, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o2, 115f);

    /* 地球 Earth:1.0AU / 半径 6371km */
    /* Earth: 1.0AU / radius 6371km */
    o_p3 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p3, ui_color_rgb(62, 126, 219));
    ui_3d_obj_set_scale(o_p3, 11f);
    ui_3d_obj_set_rot(o_p3, 0.0f, 0.0f, 23.4f);
    ui_3d_obj_set_spin(o_p3, 0.0f, 42f, 0.0f);
    ui_3d_obj_set_orbit(o_p3, 0.0f, 0.0f, 0.0f, 145f, 60000.0f, 94f);   /* 地球 1.0 年(= 60 秒)*/
                                                                        /* Earth 1.0 yr (= 60 s) */
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o3 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o3, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o3, 145f);

    /* 火星 Mars:1.52AU / 半径 3390km */
    /* Mars: 1.52AU / radius 3390km */
    o_p4 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p4, ui_color_rgb(193, 85, 46));
    ui_3d_obj_set_scale(o_p4, 10f);
    ui_3d_obj_set_rot(o_p4, 0.0f, 0.0f, 25.2f);
    ui_3d_obj_set_spin(o_p4, 0.0f, 36f, 0.0f);
    ui_3d_obj_set_orbit(o_p4, 0.0f, 0.0f, 0.0f, 180f, 112848.0f, 141f); /* 火星 1.8808 年 */
                                                                        /* Mars 1.8808 yr */
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o4 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o4, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o4, 180f);

    /* 木星 Jupiter:5.2AU / 半径 69911km */
    /* Jupiter: 5.2AU / radius 69911km */
    o_p5 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p5, ui_color_rgb(216, 180, 138));
    ui_3d_obj_set_scale(o_p5, 20f);
    ui_3d_obj_set_rot(o_p5, 0.0f, 0.0f, 3.1f);
    ui_3d_obj_set_spin(o_p5, 0.0f, 25f, 0.0f);
    ui_3d_obj_set_orbit(o_p5, 0.0f, 0.0f, 0.0f, 235f, 711720.0f, 188f); /* 木星 11.862 年 */
                                                                        /* Jupiter 11.862 yr */
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o5 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o5, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o5, 235f);

    /* 土星 Saturn:9.58AU / 半径 58232km */
    /* Saturn: 9.58AU / radius 58232km */
    o_p6 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p6, ui_color_rgb(224, 200, 138));
    ui_3d_obj_set_scale(o_p6, 18f);
    ui_3d_obj_set_rot(o_p6, 0.0f, 0.0f, 26.7f);
    ui_3d_obj_set_spin(o_p6, 0.0f, 22f, 0.0f);
    ui_3d_obj_set_orbit(o_p6, 0.0f, 0.0f, 0.0f, 290f, SATURN_PERIOD, 235f);
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o6 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o6, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o6, 290f);

    /* 天王星 Uranus:19.2AU / 半径 25362km */
    /* Uranus: 19.2AU / radius 25362km */
    o_p7 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p7, ui_color_rgb(143, 216, 224));
    ui_3d_obj_set_scale(o_p7, 14f);
    ui_3d_obj_set_rot(o_p7, 0.0f, 0.0f, 97.8f);
    ui_3d_obj_set_spin(o_p7, 0.0f, 19f, 0.0f);
    ui_3d_obj_set_orbit(o_p7, 0.0f, 0.0f, 0.0f, 350f, 5041200.0f, 282f);/* 天王星 84.02 年 */
                                                                        /* Uranus 84.02 yr */
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o7 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o7, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o7, 350f);

    /* 海王星 Neptune:30.1AU / 半径 24622km */
    /* Neptune: 30.1AU / radius 24622km */
    o_p8 = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_color(o_p8, ui_color_rgb(74, 111, 216));
    ui_3d_obj_set_scale(o_p8, 14f);
    ui_3d_obj_set_rot(o_p8, 0.0f, 0.0f, 28.3f);
    ui_3d_obj_set_spin(o_p8, 0.0f, 17f, 0.0f);
    ui_3d_obj_set_orbit(o_p8, 0.0f, 0.0f, 0.0f, 405f, 9887400.0f, 329f);/* 海王星 164.79 年 */
                                                                        /* Neptune 164.79 yr */
    /* 轨道线:与行星同一个 obj 的轨道参数,但用细环 mesh —— 半径一致 ⇒ 线正好压在轨道上 */
    /* Orbit line: same orbit parameters as the planet obj, but using the thin-ring mesh - same radius => the line sits exactly on the orbit */
    o_o8 = ui_3d_obj_create(vp, m_orbit);
    ui_3d_obj_set_color(o_o8, ui_color_rgb(64, 64, 64));
    ui_3d_obj_set_scale(o_o8, 405f);

    /* 土星(= o_p6)另给一个别名:判据 3 的探针按 `ui_3d_obj_get_screen(o_sat)` 找它 */
    /* Saturn (= o_p6) gets an alias: criterion 3's probe locates it via `ui_3d_obj_get_screen(o_sat)` */
    o_sat = o_p6;

    /* 土星环:土星的**子物体**(父的 scale 会乘下来 ⇒ 环的外径 = 10.86 × 2.25 ≈ 24.4)。
     * Saturn ring: a **child of Saturn** (the parent's scale multiplies down => ring outer diameter = 10.86 x 2.25 ~ 24.4).
     * 自转轴**先内转再倾斜**(rot(0,0,26.7)+spin(0,30,0) ⇒ R = Rz(26.7)·Ry(30t)),
     * The spin axis is **first rotated about itself, then tilted** (rot(0,0,26.7)+spin(0,30,0) => R = Rz(26.7)*Ry(30t)),
     * 于是倾角固定、环跟着轴一起走 —— 反过来写(先倾斜后自转)环会**进动**,看着像抖。
     * so the tilt is fixed and the ring follows the axis - written the other way round (tilt first, then spin) the ring **precesses** and looks like it is shaking. */
    o_ring = ui_3d_obj_create(vp, m_ring);
    ui_3d_obj_set_parent(o_ring, o_p6);
    ui_3d_obj_set_color(o_ring, ui_color_rgb(255, 255, 233)); /* 提亮 */
                                                              /* Brightened */

    /* 月球:**地球的子物体**,绕父公转(中心 (0,0,0) = 父的位置)。
     * Moon: **a child of Earth**, orbiting its parent (centre (0,0,0) = the parent's position).
     * **这就是 `ui_3d_obj_set_parent` 存在的理由** —— 没有父子就只能每帧回来算地球的位置,
     * **This is the very reason `ui_3d_obj_set_parent` exists** - without parenting we would have to compute Earth's position every frame,
     * 那正是判据 2(稳态帧零几何调用)要抓的东西。
     * which is exactly what criterion 2 (zero geometry calls in steady state) is meant to catch.
     * ⚠ 半径与轨道**都在父的局部坐标系里**(父的 scale 会乘下来):
     * Radius and orbit are **both in the parent's local coordinate system** (the parent's scale multiplies down):
     *   想让月球看起来是半径 2.0 ⇒ 本地 scale = 2.0 / 5.60 ≈ 0.36;
     *   to make the moon look like radius 2.0 => local scale = 2.0 / 5.60 ~ 0.36;
     *   轨道半径同理 = 12 / 5.60 ≈ 2.14。
     *   likewise orbit radius = 12 / 5.60 ~ 2.14. */
    o_moon = ui_3d_obj_create(vp, m_sphere);
    ui_3d_obj_set_parent(o_moon, o_p3);
    ui_3d_obj_set_color(o_moon, ui_color_rgb(191, 191, 191)); /* 灰白 */
                                                              /* Grey-white */
    ui_3d_obj_set_scale(o_moon, 0.36f);
    ui_3d_obj_set_spin(o_moon, 0.0f, 25.0f, 0.0f);
    ui_3d_obj_set_orbit(o_moon, 0.0f, 0.0f, 0.0f, 2.14f, 1100.0f, 0.0f);

    /* ── 自检期间**先画一帧提示** ─────────────────────────────────────────────
     * -- Draw one hint frame **before the self-check** --
     * 自检要按 1 像素步长读 81×81 个像素(手机上实测约 7 秒),期间没有 `ui_2d_present`
     * The self-check reads 81x81 pixels at a 1-pixel step (~7 s measured on a phone), during which there is no `ui_2d_present`
     * ⇒ 屏上一直停在开窗那一刻的空白。用户看到的就是「点开之后黑着不动,过一会儿才出画面」。
     * => the screen stays at the blank state from the moment the window opened. The user sees "it opens black and does nothing, then the picture appears".
     * ⇒ 先把这一帧送出去,自检期间屏幕就停在这儿,最后一行再换成正式画面。
     * => Send this one frame first so the screen holds here during the self-check, then the last line swaps in the real picture.
     * ⚠ 它必须在**自检之前**:自检里会 `ui_3d_viewport_set_time` 定住时钟,那一帧渲染的是"此刻的场景"。
     * It must come **before the self-check**: the self-check pins the clock with `ui_3d_viewport_set_time`, so that frame renders "the scene right now". */
    ui_2d_clear(0);
    ui_2d_text(14, 12, "太阳系 Solar System", C_WHITE, 16, VML_ANCHOR_LEFT);
    ui_2d_text(14, 34, "正在初始化 3D 场景…", C_WARN, 13, VML_ANCHOR_LEFT);
    ui_2d_present();

    /* ══ 自检 ①:运动真的发生(判据 1)—— 数值,不是"看着在动" ══════════
     * Self-check 1: motion really happens (criterion 1) - by numbers, not by "it looks like it moves"
     * 把时钟**定住**在两个时刻,问同一颗行星的**投影后屏幕坐标**。
     * Pin the clock at two instants and ask for the **projected screen coordinate** of the same planet. */
    ui_3d_viewport_set_time(vp, 0);
    sc0 = ui_3d_obj_get_screen(o_p1);
    ui_3d_viewport_set_time(vp, PLANET1_PERIOD / 2.0f);
    sc1 = ui_3d_obj_get_screen(o_p1);
    ui_3d_viewport_set_time(vp, -1);                            /* 交回宿主实时时钟 */
                                                                /* Hand the clock back to the host's real-time clock */
    printf("S0=%d\n", sc0);
    printf("S1=%d\n", sc1);
    printf("S0X=%d\n", sc0 & 0xFFFF);
    printf("S0Y=%d\n", (sc0 >> 16) & 0xFFFF);
    printf("S1X=%d\n", sc1 & 0xFFFF);
    printf("S1Y=%d\n", (sc1 >> 16) & 0xFFFF);

    /* ══ 自检 ②:深度真的在工作(判据 3)══════════════════════════════════
     * Self-check 2: depth really works (criterion 3)
     * 先把时钟定住,保证两次读的是**同一帧场景**(只有深度那一位不同)。
     * Pin the clock first so both reads see the **same scene frame** (only the depth bit differs). */
    ui_3d_viewport_set_time(vp, 3000.0f);
    sc0 = ui_3d_obj_get_screen(o_sat);
    printf("SAT=%d\n", sc0);
    /* ⚠ 标签由 `probe_grid` 自己打(全量时是 `GRIDON=`,摘要时是 `GRIDON-DIGEST=`)——
     * The tag is printed by `probe_grid` itself (full grid: `GRIDON=`; digest: `GRIDON-DIGEST=`) -
     *   这里再打一次会变成 `GRIDON=GRIDON=…`,判据脚本的 `^GRIDON=` 会去解析后面那半截。
     *   printing it again here would give `GRIDON=GRIDON=...`, and the check script's `^GRIDON=` would parse the second half. */
    ui_3d_viewport_set_depth(vp, 1);
    probe_grid(vp, sc0, sw, sh, "GRIDON");
    ui_3d_viewport_set_depth(vp, 0);
    probe_grid(vp, sc0, sw, sh, "GRIDOFF");
    ui_3d_viewport_set_depth(vp, 1);
    ui_3d_viewport_set_time(vp, -1);

    /* ══ 自检 ④:**交回宿主时钟之后,运动还得继续**(判据 4)══════════════════
     * Self-check 4: **after handing the clock back to the host, motion must continue** (criterion 4)
     * ⚠⚠ 这条是**补上的**,而且非补不可 —— 判据 1 用的是 `ui_3d_viewport_set_time` **钉住**
     * This one was **added later** and is indispensable - criterion 1 uses two instants **pinned** by `ui_3d_viewport_set_time`,
     *   的两个时刻,那条路**即使「交回宿主时钟」这一句失效也照样通过**。
     *   and that path **still passes even if the "hand the clock back" line is broken**.
     *   实测(2026-10-04):判据 1 一直是绿的,而行星在**真实时间**里**一个都不动**。
     *   Measured (2026-10-04): criterion 1 was green the whole time while, in **real time**, **not one planet moved**.
     *   根因在绑定层:`ui_3d_viewport_set_time` 的 `ms` 被声明成 `int`,而宿主按
     *   Root cause in the binding layer: `ui_3d_viewport_set_time`'s `ms` was declared `int`, while the host reads the register
     *   float32 的**位模式**读寄存器 ⇒ `-1` 的位模式 `0xFFFFFFFF` 是 **NaN** ⇒ 宿主拒绝
     *   as a float32 **bit pattern** => `-1`'s bit pattern `0xFFFFFFFF` is **NaN** => the host rejected
     *   这个参数 ⇒ 那句「交回宿主时钟」**静默失效** ⇒ 视口永久停在手动挡。
     *   the parameter => the "hand the clock back" line **silently failed** => the viewport stayed on manual forever.
     * ⇒ 判据必须问**真实时间**那一维:交回之后隔一段真实时间再读一次,坐标必须变。
     * => The criterion must ask about the **real-time** dimension: read again after some real time has passed and the coordinate must change.
     *   反证:把下面这句 `-1` 改成 `3000.0f`(= 不交回)⇒ 两次读数必须相同 ⇒ 这条立刻红。
     *   Counter-check: change the `-1` below to `3000.0f` (= do not hand back) => the two reads become identical => this goes red at once. */
    ui_msg_fetch_wait(1500);                     /* 真等 1.5 秒实时(不是钉住时钟) */
                                                 /* Really wait 1.5 s of real time (not a pinned clock) */
    sc0 = ui_3d_obj_get_screen(o_p3);            /* 地球 */
                                                 /* Earth */
    ui_msg_fetch_wait(1500);
    sc1 = ui_3d_obj_get_screen(o_p3);
    printf("RT0=%d\n", sc0);
    printf("RT1=%d\n", sc1);
    printf("RTMOVED=%d\n", sc0 == sc1 ? 0 : 1);

    /* ══ 主循环:**每帧只有 ui_* 与 ui_2d_present** ══════════════════════════
     * Main loop: **each frame only ui_* and ui_2d_present**
     * 没有任何 g3_obj_* —— 运动是声明的,宿主自己算。
     * No g3_obj_* at all - motion is declared and the host computes it. */
    frame = 0;
    paused = 0;
    speed = 1000;                 /* 1000 = 1x ⇒ 1 分钟 = 1 年 */
                                  /* 1000 = 1x => 1 minute = 1 year */
    sceneMs = 0; clockMs = ui_time_get_ms();
    /* 初始角**对准 ui_3d_viewport_set_camera 给的方向**(眼睛 (0,620,560) 看原点),否则一开窗画面会跳:
     * pitch = asin(620/835.5) ≈ 47.9°,yaw = atan2(0,560) = 0°。 */
    camYaw = 0.0f;
    camPitch = 47.9f;
    camZoom = 1.0f;
    pinching = 0; lastPinch = 0.0f;
    dragging = 0;
    lastTx = 0; lastTy = 0;
    while (ui_win_is_closed() == 0) {
        if (ui_key_is_down(VML_KEY_ESCAPE)) break;
        if (ui_key_is_down(VML_KEY_SPACE)) {           /* 暂停/继续(回弹靠 paused 位) */
                                                       /* Pause/resume (debounce via the paused flag) */
            if (paused == 0) { paused = 1; ui_3d_viewport_set_clock(vp, 1, 0); }
        } else {
            if (paused == 1) { paused = 0; ui_3d_viewport_set_clock(vp, 0, speed); }
        }
        if (ui_key_is_down(VML_KEY_UP)) {
            speed = speed + 250;
            if (speed > 4000) speed = 4000;
            if (paused == 0) ui_3d_viewport_set_clock(vp, 0, speed);
        }
        if (ui_key_is_down(VML_KEY_DOWN)) {
            speed = speed - 250;
            if (speed < 250) speed = 250;
            if (paused == 0) ui_3d_viewport_set_clock(vp, 0, speed);
        }

        /* ── 触摸拖拽 = 旋转整个坐标系 ────────────────────────────────────
         * -- Touch drag = rotate the whole coordinate system --
         * **太阳(= 坐标原点 = 注视点)永远钉在屏幕中心**:转的是相机、不是物体(刚体变换,
         * **The sun (= origin = look-at point) is always pinned to the screen centre**: the camera rotates, not the objects (a rigid transform;
         * 视觉上整个星系在转)。x / y 两个方向都能转。
         * visually the whole galaxy turns). Both x and y can rotate.
         * ⚠ 用「本帧位置 − 上次位置」的**增量**累加,不用绝对位移 —— 手指从边缘重新落下时
         * Accumulate the **delta** between this frame's position and the last, not the absolute displacement - when a finger lands again from the edge
         *   绝对位移会跳一大截(本仓 E106「基准取实际值不取请求值」同族)。
         *   the absolute displacement jumps badly (same family as repo rule E106, "take the actual value as the baseline, not the requested one"). */
        if (ui_touch_read(0, touch)) {
            if (touch[2]) {
                if (dragging) {
                    /* ⚠ **横向要取负号**(用户实测:「左右滑动是反向的,上下滑动是对的」,
                     * **Horizontal must be negated** (the user measured it: "left/right sliding is reversed, up/down is correct";
                     *   安卓与苹果一个样 ⇒ 不是平台问题,是这条映射本身)。
                     *   identical on Android and Apple => not a platform issue, it is this mapping itself).
                     *   `ui_3d_viewport_set_orbit` 的 yaw 是**球坐标标准式**(`ex = Tx + d·cos·sin(yaw)`,
                     *   `ui_3d_viewport_set_orbit`'s yaw uses the **standard spherical formula** (`ex = Tx + d*cos*sin(yaw)`,
                     *   与 three.js 的 `OrbitControls` 同一条),而那边拖右是 `rotateLeft`
                     *   the same one three.js's `OrbitControls` uses), and there dragging right is `rotateLeft`
                     *   ⇒ **θ 减小**。竖直之所以"对",是因为 `pitch` 增大让相机升高、
                     *   => **theta decreases**. Vertical is "correct" because increasing `pitch` raises the camera
                     *   画面跟着手指往下走 —— 横向要的是**同一种"拖着画面走"的手感**,
                     *   and the image follows the finger downwards - horizontal wants the **same "drag the image along" feel**,
                     *   所以 Δx 取负。别在宿主里翻 yaw:那会把标准式改成非标准,
                     *   hence dx is negated. Do not flip yaw in the host: that would turn the standard formula into a non-standard one,
                     *   而"拖右 ⇒ 画面右移"本来就是**交互层**该定的符号。
                     *   and "drag right => image moves right" is a sign the **interaction layer** should decide anyway. */
                    camYaw = camYaw - (touch[0] - lastTx) * 0.5f;
                    camPitch = camPitch + (touch[1] - lastTy) * 0.5f;
                    /* 钳 ±88°:到 90° 视线与 up(+Y) 共线、画面会翻(宿主侧也钳,这是第二道) */
                    /* Clamp to +-88 deg: at 90 the view direction is collinear with up (+Y) and the image flips (the host clamps too; this is the second guard) */
                    if (camPitch > 88.0f) camPitch = 88.0f;
                    if (camPitch < -88.0f) camPitch = -88.0f;
                }
                dragging = 1;
                lastTx = touch[0]; lastTy = touch[1];
            } else {
                dragging = 0;
            }
        } else {
            dragging = 0;
        }
        /* ── 两指开合 = 拉近拉远 ────────────────────────────────────────────
         * -- Two-finger pinch = zoom in/out --
         * 用**两指间距的比值**(本帧 / 上一帧)累乘,不用绝对距离 —— 与单指拖拽同一条理由:
         * Multiply by the **ratio of the two-finger distance** (this frame / last frame), not the absolute distance - same reason as the single-finger drag:
         * 手指中途落下/抬起时绝对量会跳。两指分开(间距变大)= 放大 ⇒ 倍率变小。
         * absolute values jump when a finger lands or lifts mid-gesture. Fingers apart (bigger distance) = zoom in => smaller multiplier.
         * ⚠ 比值要挡小值:两指几乎重合时 `lastPinch` 接近 0 ⇒ 除零得 inf ⇒ 画面直接没了。
         * The ratio must guard against small values: when the fingers nearly coincide `lastPinch` approaches 0 => division by zero gives inf => the image disappears. */
        if (ui_touch_read(0, t0) && ui_touch_read(1, t1) && t0[2] && t1[2]) {
            float ddx; float ddy; float dd;
            ddx = (float)(t0[0] - t1[0]);
            ddy = (float)(t0[1] - t1[1]);
            dd = sqrt(ddx * ddx + ddy * ddy);
            if (pinching && dd > 8.0f && lastPinch > 8.0f) {
                camZoom = camZoom * (lastPinch / dd);
                if (camZoom < 0.15f) camZoom = 0.15f;
                if (camZoom > 6.0f) camZoom = 6.0f;
            }
            lastPinch = dd; pinching = 1;
        } else {
            pinching = 0;
        }
        ui_3d_viewport_set_zoom(vp, camZoom);

        ui_3d_viewport_set_orbit(vp, camYaw, camPitch);

        /* 场景时钟推进:暂停时不走;速度与宿主同一个 `speed`(1000 = 1x = 1 分钟 1 年)。 */
        /* Scene-clock advance: frozen while paused; uses the same `speed` as the host (1000 = 1x = 1 minute per year). */
        {
            int nowMs = ui_time_get_ms();
            if (paused == 0) sceneMs = sceneMs + (nowMs - clockMs) * speed / 1000;
            clockMs = nowMs;
        }

        /* HUD:**2D 画在 3D 之上**(§十四 的 HUD 归属)。ui_2d_clear 用全透明 ——
         * HUD: **2D is drawn on top of 3D** (section 14's HUD ownership). ui_2d_clear uses fully transparent -
         * 出图时 3D 那一层是底,2D 图元直接叠上去。
         * when presenting, the 3D layer is the base and the 2D primitives are laid straight on top. */
        ui_2d_clear(0);
        ui_2d_text(14, 12, "太阳系 Solar System", C_WHITE, 16, VML_ANCHOR_LEFT);
        ui_2d_text(14, 34, "拖动旋转   两指开合缩放   Esc 退出", C_HUD, 11, VML_ANCHOR_LEFT);
        {
            char ybuf[64];
            sprintf(ybuf, "第 %d 年   (1 分钟 = 1 年;上下键调速)", (int)(sceneMs / 60000) + 1);
            ui_2d_text(14, 52, ybuf, C_HUD, 11, VML_ANCHOR_LEFT);
        }
        if (paused == 1) ui_2d_text(14, 70, "[暂停 PAUSED]", C_WARN, 12, VML_ANCHOR_LEFT);

        /* 8 颗行星的名字(+ 月球):每帧按宿主报的**投影坐标**贴上去。
         * Names of the 8 planets (+ the moon): attached every frame at the **projected coordinates** the host reports.
         * ⚠ 必须在 `ui_2d_present()` **之前** —— 它是这一帧 2D 层的一部分。
         * Must run **before** `ui_2d_present()` - it is part of this frame's 2D layer. */
        label(o_p1, "水星");   label(o_p2, "金星");
        label(o_p3, "地球");   label(o_p4, "火星");
        label(o_p5, "木星");   label(o_p6, "土星");
        label(o_p7, "天王星"); label(o_p8, "海王星");
        label(o_moon, "月球");
        ui_2d_present();
        /* 帧节流:**不在这里做**(用户 2026-10-04 的口径:「不应该是间隔多久绘制,
         * Frame throttling is **not done here** (the user's wording on 2026-10-04: "it should not be draw every so often,
         * 而是空闲了就绘制」)。这一句只是"等输入、最多等这么久"——
         * but draw whenever idle"). This line only means "wait for input, up to this long" -
         * **真正的上屏节奏由宿主管**:`ui_2d_present()` 出来时宿主已经跟着显示走了一帧
         * **the real presentation rhythm is the host's job**: by the time `ui_2d_present()` returns the host has already followed the display for one frame
         * (直渲那条路是 `eglSwapBuffers` 阻塞到 vsync;离屏那条路是宿主的帧预算),
         * (the direct-render path blocks in `eglSwapBuffers` until vsync; the offscreen path follows the host's frame budget),
         * 而这里给的时间会被宿主**夹到"距下一帧还有多久"**(`VmlHostRuntime.ClampToFrame`)。
         * and the time given here is **clamped by the host to "how long until the next frame"** (`VmlHostRuntime.ClampToFrame`).
         * ⚠ 所以:**不要再靠改这个数去调帧率** —— 那是错的模型(40ms 当节流既压上限又白加延迟,
         * So: **do not tune the frame rate by changing this number any more** - that is the wrong model (40 ms as a throttle both caps the ceiling and adds pointless latency;
         *   实测只有 20~22fps,而宿主接管之后同一份程序是 60fps 节奏、忙不过来才掉)。
         *   measured only 20-22 fps, while after the host took over the same program runs at a 60 fps rhythm and only drops when overloaded). */
        ui_msg_fetch_wait(40);

        frame = frame + 1;
        if (RUN_FRAMES > 0) { if (frame >= RUN_FRAMES) break; }
    }

    printf("FRAMES=%d\n", frame);
    printf("DONE=1\n");
    return 0;
}