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

c/3d/rover3d.c · 84543 字节 · 下载原文

/* rover3d.c —— **3D 漫游寻果**:一局"开着车在起伏地形上收果子"的游戏,同时也是整套 3D 能力的**总验收**。
 * rover3d.c - **3D Rover Harvest**: drive around rolling terrain collecting fruit; also the end-to-end
 * acceptance for the whole 3D feature set (glTF loading through to 1024 objects).
 *
 * ## 为什么是"游戏"而不是又一个单项例程
 * ## Why a game instead of one more single-feature example
 *
 * 单项例程可以各自全绿,**合起来却是坏的**。
 * Single-feature examples can each be green while the whole thing is still broken:
 * `g3_all.c`(逐号验收)、`gltf_demo`(装载)、`gamma_demo`(伽马/镂空/采样器)、
 * g3_all (per syscall), gltf_demo (loading), gamma_demo (gamma / cut-out / sampler),
 * `pbr_demo`(PBR/光源/环境反射)、`shadowmap_demo`(阴影贴图)各自都在测**自己那一维**。
 * pbr_demo (PBR / lights / environment) and shadowmap_demo each measure their own dimension.
 * 一局游戏跑起来 = 把**多个维度同时**量一遍:同一帧里要有真模型、镂空栅栏、山坡上的影子、
 * A running game measures every dimension at once: one frame must show a real model, a cut-out
 * 金属高光、以及 300 多个物体 —— 而且**任何一维坏掉都会表现为"画面不对"**。
 * fence, a shadow on a slope, a metal highlight and 300+ objects - a broken dimension shows up as
 * 不是"某条断言红了"。
 * "the picture is wrong", never as "an assertion went red somewhere".
 * A running game measures **every dimension at once**: one frame must show a real model, a cut-out
 * fence, a shadow on a slope, a metal highlight and 300+ objects - and a broken dimension shows up as
 * "the picture is wrong", never as "an assertion went red somewhere".
 *
 * ## 素材(各自许可,都是 Khronos glTF-Sample-Assets 的下载件)
 * ## Assets (licences listed; all downloaded from Khronos glTF-Sample-Assets)
 *
 *   · `ToyCar.glb` —— **CC0**(babylon.js glTF exporter for 3dsmax 2020)。
 *   · ToyCar.glb - CC0 (babylon.js glTF exporter for 3dsmax 2020).
 *   · `ToyCar.glb` - **CC0**; 11 nodes / 3 meshes / 77,429 verts / 8 textures / 3 materials.
 *     它是**玩家开的车**,也是 PBR 与材质对比的载体(车漆金属 vs 织物哑光)。
 *     It is the **car the player drives** and the PBR carrier (metallic paint vs matte fabric).
 *   · `Avocado.glb` —— **CC0**(Microsoft),1 节点 / 1 网格 / 406 顶点 / 3 张 2048² 贴图。
 *   · `Avocado.glb` - **CC0** (Microsoft); 1 node / 1 mesh / 406 verts / 3 2048-square textures.
 *     它是**要收集的果子**(8 个)。
 *     It is the fruit to collect (eight of them).
 *   · `rover_cutout.glb` —— **本仓自产**(`scripts/make-rover-cutout-glb.py`)。
 *   · rover_cutout.glb - **built in this repo** (scripts/make-rover-cutout-glb.py):
 *     一张自绘的镂空图集 + 栅栏 + 树干 + 树冠。**必须自产的原因**见那个脚本的头部。
 *     a hand-drawn cut-out atlas plus fence, trunks and canopies. See that script's header -
 *     程序侧造不出带 alpha 的贴图:`ui_2d_get_image` 一律把 alpha 补成 255。
 *     a program cannot build an alpha-bearing texture: ui_2d_get_image fills alpha in as 255.
 *     (`ui_2d_rect` 的 0x00000000 也是"什么都不做",实测擦不出洞。)
 *     (a 0x00000000 ui_2d_rect is a no-op too - measured, it cannot punch holes either.)
 *   · `rover_cutout.glb` - **built in this repo** (`scripts/make-rover-cutout-glb.py`): a hand-drawn
 *     cut-out atlas plus fence, trunks and canopies. See that script's header for why it is home-made:
 *     只有 glTF 的 `alphaMode: MASK` 那条路能带 alpha 上 GPU,而下载语料里**没有**树叶/栅栏件。
 *     only glTF's `alphaMode: MASK` path can get alpha onto the GPU, and the downloaded corpus has no
 *     foliage or fence cut-out at all.
 *
 * ⚠ 两件下载件都是 **CC0**(无需署名,这里仍然把出处写出来)。
 * ⚠ Both downloads are **CC0** (attribution not required; the credit is here anyway).
 * ⚠ 没有用任何 CC-BY-NC / 仅限测试授权的语料。
 * ⚠ No CC-BY-NC or "testing only" corpus was used.
 *
 * ## 玩法
 * ## How to play
 *
 * 方向键 / WASD 开车(↑ 加速、↓ 倒车、←→ 转向),Space 重开,G 切输出变换(0 关 / 3 ACES)。
 * Arrow keys / WASD drive; Space restarts; G cycles the output transform (0 off / 3 ACES).
 * 右上角小地图是**正交**俯视。把 8 个果子全收完 = 通关,90 秒收不完 = 超时。
 * The top-right minimap is an **orthographic** top view; collect all 8 fruit to win, 90 s or you lose.
 * ⚠ 默认跑的是**自检模式**(自动驾驶一段、逐条判据、末行给结论)。想自己玩:`-DROVER_PLAY=1`。
 * ⚠ By default it runs the **self-check** (autopilot, then every criterion, then a summary line).
 * 或者把下面 `ROVER_PLAY` 改成 1 再编译;只想看画面可以再加 `-D ROVER_CHECK=0` 跳过判据。
 * To play it yourself pass `-DROVER_PLAY=1`, or change `ROVER_PLAY` below to 1; add `-D ROVER_CHECK=0`
 * to skip the criteria and just watch the picture.
 *
 * ## 判据(每条都能响;末行 `ROVER3D_GAME PASS|FAIL (fails=N)`)
 * ## Criteria (every one can go red; the summary line is `ROVER3D_GAME PASS|FAIL (fails=N)`)
 *
 *   ① 装载 / 物体数 / 真模型的几何;② 输出变换(伽马);③ 逐盏灯都得有效果。
 *   (1) loading / object count / real-model geometry; (2) the output transform; (3) every light matters.
 *   ④ 阴影贴图落在**起伏地形**上(平面投影够不着)、且**跟着光转**;⑤ 镂空边缘真的被丢弃。
 *   (4) the shadow map lands on **rolling** terrain and **follows the light**; (5) cut-out really cut.
 *   ⑥ mipmap 把远处地面的闪烁抹掉;⑦ PBR 与**环境反射**(金属照得出天)。
 *   (6) mipmap kills the far-ground shimmer; (7) PBR and **environment reflection**.
 *   ⑧ 小地图的**正交**(远近同大);⑨ 移动 / 收集 / 计数 / 结束条件(收满 = 通关,超时 = 结束)。
 *   (8) the minimap's **orthographic** view; (9) move / collect / count / end conditions.
 *
 * ## 跑法
 * ## How to run
 *   桌面:`dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/3d/rover3d.c --screen 393x757`
 *   Desktop: `dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll .../rover3d.c --screen 393x757`
 *   ⚠ **出图要用 `--frames <目录>`,别用 `--frame`**(`--frame` 取的是运行结束时那一帧的 FBO)。
 *   ⚠ **Export with `--frames <dir>`, not `--frame`** (the latter grabs the FBO at exit, after the
 *   本程序结束时相机已经摆回游戏视点,而判据段又改过一堆状态 ⇒ 拿到的不是想看的那一张。
 *   criteria have moved the camera and toggled half the features).
 *   手机:命令行页敲 `vml run examples/c/3d/rover3d.c`(素材在 `examples/c/3d/*.glb`,随包分发)。
 *   Phone: type `vml run examples/c/3d/rover3d.c` on the command-line page (the .glb assets ship too).
 */
#include <vml_ui.h>
#include <vml_3d.h>
#include <math.h>
#include <stdio.h>

/* 1 = 纯手动玩(不跑判据);0 = 自检模式(默认)。命令行 `-DROVER_PLAY=1` 可以覆盖。 */
/* 1 = play by hand (no criteria); 0 = self-check (default). Overridable with `-DROVER_PLAY=1`. */
#ifndef ROVER_PLAY
#define ROVER_PLAY 0
#endif

/* 1 = 跑判据(默认);0 = **跳过判据直接玩**(调画面时用,几十秒省成几秒)。 */
/* 1 = run the criteria (default); 0 = **skip straight to the game**, for fast visual iteration. */
#ifndef ROVER_CHECK
#define ROVER_CHECK 1
#endif

/* 自检模式下的**兜底帧上限**(正常应当在收满/超时时就结束;这一段就是 `--frames` 出来的那些)。 */
/* The autopilot's hard frame cap in self-check mode; normally the round ends first. */
#define AUTO_FRAMES 220

/* ── 世界常量(与 `scripts/make-rover-cutout-glb.py` 里那几张表**必须一致**)────────────── */
/* -- World constants that **must match** the tables in `scripts/make-rover-cutout-glb.py` -- */
/* 栅栏半径。 */
/* fence radius. */
#define FENCE_R     46.0f
/* 玩家活动半径(栅栏里面一点)。 */
/* player radius, just inside the fence. */
#define ARENA       43.0f
#define CAR_SCALE   70.0f      /* ToyCar 原始包围球 r=0.054 ⇒ ×70 后约 7.6 单位长 */
                               /* ToyCar's raw bounds radius is 0.054; x70 gives about 7.6 units long */
/* 车心离地(模型原点在包围球中心)。 */
/* car centre above ground; the model origin is the bounding-sphere centre. */
#define CAR_LIFT    1.55f
/* Avocado 原始包围球 r=0.040,乘 46 之后约 3.7 单位。 */
/* Avocado raw r=0.040; x46 gives about 3.7 units. */
#define FRUIT_SCALE 46.0f
/* 果子浮起来自转,好认。 */
/* the fruit floats and spins so it reads. */
#define FRUIT_LIFT  1.5f
/* 果子数。 */
/* number of fruit. */
#define NF          8
/* 石头数 —— 物体数主要靠它们撑过 256。 */
/* rocks; these are what push the object count past 256. */
#define NROCK       200
/* 金属球 + 底座的对数。 */
/* metal sphere plus pedestal pairs. */
#define NMETAL      10
/* 树数(与 `make-rover-cutout-glb.py` 的 N_TREE 一致)。 */
/* trees; must match N_TREE in make-rover-cutout-glb.py. */
#define NTREE       24
/* 树的落点公式起点(两处必须一样)。 */
/* tree placement formula, first term; the two copies must match. */
#define TREE_RR0    16.0f
#define TREE_RR1    21.0f
#define BODY_R      2.2f       /* 树干挡车的半径(车当质点看,别把通道堵死)*/
                                   /* trunk push-out radius; the car is treated as a point */
/* 金属球挡车的半径(球半径 + 车宽)。 */
/* sphere radius plus car half-width. */
#define METAL_R     3.6f
/* 用满 5 盏(宿主上限是 8)。 */
/* five of the eight available lights are used. */
#define NLIGHT      5
/* 收果半径(够宽容,但明显小于果子间距)。 */
/* collect radius: forgiving, yet clearly smaller than the spacing between fruit. */
#define COLLECT_R   4.6f
/* 一局多少秒。 */
/* seconds per round. */
#define ROUND_SEC   90.0f
/* 加速度(单位/秒²)。 */
/* acceleration in units per second squared. */
#define ACC         17.0f
/* 最高速。 */
/* top speed. */
#define VMAX        15.0f
/* 转向速率(度/秒)。 */
/* turn rate in degrees per second. */
#define TURN_RATE   135.0f
/* 速度阻尼。 */
/* speed drag. */
#define DRAG        1.35f

/* ── 颜色(0xAARRGGBB)───────────────────────────────────────────────────── */
/* -- Colours (0xAARRGGBB) -- */
#define C_DARK      0xFF0B1220
#define C_WHITE     0xFFFFFFFF
#define C_HUD       0xFFE8F0E8
#define C_HUD_DIM   0xFF9FB09F
#define C_WARN      0xFFFF6A4A
#define C_GOOD      0xFF7CFF9A
#define C_GOLD      0xFFFFD24A
#define C_ROAD      0xFF6E7A5E

/* ══ 网格缓冲(顶点 = pos3 + nrm3 + uv2 = 8 个 float32,紧跟 int32 索引)══════════════ */
/* == Mesh buffer (vertex = pos3 + nrm3 + uv2 = 8 float32, then int32 indices) == */
/* ⚠ 容量必须装得下「顶点数 × 8 + 索引数」:地形 48 段 = 49×49×8 + 48×48×6 = 33032。
 * ⚠ The array must hold (verts x 8 + indices): terrain at 48 segments = 33032. Writing less
 *   不报错,只是把后面的全局变量整片写烂(本仓栽过:判据以"莫名其妙"的形式变红)。
 *   corrupts the globals after it **without any error** (this repo has been bitten by that). */
#define MB_CAP 36000
float MB[MB_CAP];
int g_vn;
int g_in;

void mb_vertex_uv(int i, float px, float py, float pz,
                  float nx, float ny, float nz, float u, float v) {
    int b;
    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] = u;  MB[b + 7] = v;
}

/* ⚠ 索引落笔在 `ip[g_vn*8 + k]`,即"紧跟顶点之后" ⇒ **必须等全部顶点写完再写索引**。
 * ⚠ Indices land at `ip[g_vn*8 + k]`, right after the vertices, so they must be written **after**
 *   边写顶点边写索引会被互相覆盖(症状:屏幕上一条窄平行四边形,而 `obj_get_screen` 照旧报正中)。
 *   every vertex is in place; interleaving the two overwrites each other. */
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]; }

/* ══ 小工具 ══════════════════════════════════════════════════════════════ */
/* == Small helpers == */
int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }
int rch(int c) { return (c >> 16) & 255; }
int gch(int c) { return (c >> 8) & 255; }
int bch(int c) { return c & 255; }
int lum(int c) { return rch(c) + gch(c) + bch(c); }

int g_fails;
/* 建出来的物体总数(判据要它)。 */
/* total objects created (the criteria read it). */
int g_objects;

void ck(char* tag, int cond, int got, int want) {
    if (cond) printf("PASS %s (%d)\n", tag, got);
    else { printf("FAIL %s (got=%d want=%d)\n", tag, got, want); g_fails = g_fails + 1; }
}

int rnd;
int rnd_next(void) {
    rnd = rnd * 1103515245 + 12345;
    rnd = (rnd >> 16) & 32767;
    return rnd;
}

float frnd(void) { return (float)(rnd_next() % 10000) / 10000.0f; }

/* ══ 地形:**起伏**高度场(阴影贴图那条判据全靠它)═══════════════════════════════ */
/* == Terrain: a **rolling** height field (the shadow-map criterion lives on this) == */
/* ⚠ 与 `scripts/make-rover-cutout-glb.py` 的 `terr_h()` **逐项相同** —— 树和栅栏的位置是按它烘进
 * ⚠ Term for term identical to `terr_h()` in the generator script: the fence and trees are baked
 *   那个 .glb 的,两边漂了就会出现"树悬在半空 / 栅栏埋进土里"。
 *   against it, and a drift between the two shows up as floating trees or a buried fence. */
float terr_h(float x, float z) {
    float h;
    h = 3.0f * sin((double)(x * 0.11f)) * cos((double)(z * 0.09f));
    h = h + 1.6f * sin((double)((x + z) * 0.055f));
    h = h + 0.9f * cos((double)((x - z) * 0.13f));
    return h;
}

/* 地形法线:对高度场做中心差分(比解析求导短,而且与网格本身一致)。 */
/* The terrain normal by central differences over the height field. */
void terr_normal(float x, float z, float d, float* out) {
    float hl; float hr; float hd; float hu;
    float nx; float nz; float ny; float len;
    hl = terr_h(x - d, z);
    hr = terr_h(x + d, z);
    hd = terr_h(x, z - d);
    hu = terr_h(x, z + d);
    nx = 0.0f - (hr - hl) / (2.0f * d);
    nz = 0.0f - (hu - hd) / (2.0f * d);
    ny = 1.0f;
    len = sqrt((double)(nx * nx + ny * ny + nz * nz));
    out[0] = nx / len; out[1] = ny / len; out[2] = nz / len;
}

void gen_terrain(int segs, float half, float rep) {
    int i; int j; int v; int k;
    float n[3];
    float fx; float fz;
    v = 0;
    for (j = 0; j <= segs; j = j + 1) {
        for (i = 0; i <= segs; i = i + 1) {
            fx = 0.0f - half + 2.0f * half * i / segs;
            fz = 0.0f - half + 2.0f * half * j / segs;
            terr_normal(fx, fz, 0.5f, n);
            mb_vertex_uv(v, fx, terr_h(fx, fz), fz, n[0], n[1], n[2],
                         rep * i / segs, rep * j / segs);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (j = 0; j < segs; j = j + 1) {
        for (i = 0; i < segs; i = i + 1) {
            int a; int b;
            a = j * (segs + 1) + i;
            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;
}

/* 长方体:**每个面给常量法线**(拿归一化位置当法线在角上会歪 45°,方块会看起来是圆的)。 */
/* A box with a **constant normal per face** (using the normalised position would round the corners). */
void mb_face(int v, float px, float py, float pz, float nx, float ny, float nz) {
    mb_vertex_uv(v, px, py, pz, nx, ny, nz, 0.0f, 0.0f);
}

void gen_box(float hx, float hy, float hz) {
    int v; int k;
    v = 0;
    mb_face(v, hx, 0 - hy, 0 - hz, 1, 0, 0); mb_face(v + 1, hx, 0 - hy, hz, 1, 0, 0);
    mb_face(v + 2, hx, hy, hz, 1, 0, 0);     mb_face(v + 3, hx, hy, 0 - hz, 1, 0, 0); v = v + 4;
    mb_face(v, 0 - hx, 0 - hy, hz, -1, 0, 0); mb_face(v + 1, 0 - hx, 0 - hy, 0 - hz, -1, 0, 0);
    mb_face(v + 2, 0 - hx, hy, 0 - hz, -1, 0, 0); mb_face(v + 3, 0 - hx, hy, hz, -1, 0, 0); v = v + 4;
    mb_face(v, 0 - hx, hy, 0 - hz, 0, 1, 0); mb_face(v + 1, hx, hy, 0 - hz, 0, 1, 0);
    mb_face(v + 2, hx, hy, hz, 0, 1, 0);     mb_face(v + 3, 0 - hx, hy, hz, 0, 1, 0); v = v + 4;
    mb_face(v, 0 - hx, 0 - hy, hz, 0, -1, 0); mb_face(v + 1, hx, 0 - hy, hz, 0, -1, 0);
    mb_face(v + 2, hx, 0 - hy, 0 - hz, 0, -1, 0); mb_face(v + 3, 0 - hx, 0 - hy, 0 - hz, 0, -1, 0); v = v + 4;
    mb_face(v, 0 - hx, 0 - hy, hz, 0, 0, 1); mb_face(v + 1, hx, 0 - hy, hz, 0, 0, 1);
    mb_face(v + 2, hx, hy, hz, 0, 0, 1);     mb_face(v + 3, 0 - hx, hy, hz, 0, 0, 1); v = v + 4;
    mb_face(v, hx, 0 - hy, 0 - hz, 0, 0, -1); mb_face(v + 1, 0 - hx, 0 - hy, 0 - hz, 0, 0, -1);
    mb_face(v + 2, 0 - hx, hy, 0 - hz, 0, 0, -1); mb_face(v + 3, hx, hy, 0 - hz, 0, 0, -1); v = v + 4;
    g_vn = v;
    k = 0;
    for (v = 0; v < 6; v = v + 1) {
        int a;
        a = v * 4;
        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);     k = k + 1;
        mb_index(k, a + 2); k = k + 1;
        mb_index(k, a + 3); k = k + 1;
    }
    g_in = k;
}

/* 球:法线取归一化位置(金属球靠它出高光与反射)。低多边形就够,石头也用它。 */
/* A sphere whose normal is the normalised position; low poly is fine, and the rocks reuse it. */
void gen_sphere(int rings, int segs) {
    int i; int j; int v; int k;
    v = 0;
    for (i = 0; i <= rings; i = i + 1) {
        float phi; float sp; float cp;
        phi = 3.14159265f * i / rings;
        sp = sin((double)phi);
        cp = cos((double)phi);
        for (j = 0; j <= segs; j = j + 1) {
            float th; float nx; float ny; float nz;
            th = 6.28318531f * j / segs;
            nx = sp * cos((double)th);
            ny = cp;
            nz = sp * sin((double)th);
            mb_vertex_uv(v, nx, ny, nz, nx, ny, nz, 2.0f * j / segs, 1.0f - 2.0f * i / rings);
            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; int b;
            a = i * (segs + 1) + j;
            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;
}

/* 水平方片(法线 +Y)—— 小地图的标记用它,俯视正交下正好正对相机。 */
/* A horizontal quad (normal +Y) - the minimap markers use it, facing a top-down camera. */
void gen_flat_quad(float half) {
    g_vn = 0; g_in = 0;
    mb_vertex_uv(0, 0 - half, 0.0f, 0 - half, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f);
    mb_vertex_uv(1, half, 0.0f, 0 - half, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f);
    mb_vertex_uv(2, half, 0.0f, half, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f);
    mb_vertex_uv(3, 0 - half, 0.0f, half, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f);
    g_vn = 4;
    mb_index(0, 0); mb_index(1, 1); mb_index(2, 2);
    mb_index(3, 0); mb_index(4, 2); mb_index(5, 3);
    g_in = 6;
}

/* ══ 程序贴图(**全都走 2D 层抓图**:`ui_2d_get_image` 给句柄)══════════════════════ */
/* == Procedural textures (all captured from the 2D layer via `ui_2d_get_image`) == */
/* ⚠ 判据要的"高频"是刻意的:地面贴图不高频,远处就不会闪,mipmap 那条判据也就**响不了**。
 * ⚠ The high frequency is deliberate: a smooth ground texture never shimmers, and then the mipmap
 *   所以草皮是"棋盘 + 斑点"而不是一片渐变的绿。
 *   criterion can never fire. Hence a checkered, speckled ground rather than a smooth green. */
int make_ground_tex(void) {
    int i; int x; int y; int img;
    ui_2d_clear(0);
    for (y = 0; y < 64; y = y + 1) {
        for (x = 0; x < 64; x = x + 1) {
            int c;
            int cell;
            cell = ((x / 4) + (y / 4)) % 2;
            if (cell == 0) c = argb(58, 88, 46);
            else c = argb(42, 68, 36);
            if (((x * 7 + y * 13) % 11) == 0) c = argb(92, 124, 66);
            if (((x * 5 + y * 3) % 17) == 0) c = argb(28, 48, 28);
            ui_2d_rect(x, y, 1, 1, c, 1, 0, 0);
        }
    }
    ui_2d_present();
    img = ui_2d_get_image(0, 0, 64, 64);
    ui_2d_clear(0);
    ui_2d_present();
    i = img;
    return i;
}

/* 等距圆柱天空:上半是天顶到地平线的渐变 + 一个太阳,下半是地面霾。
 * An equirectangular sky: a zenith-to-horizon gradient plus a sun on top, ground haze below.
 * 它同时是**背景**与**环境反射的源**(开了 PBR 才参与光照)。 */
/* It is both the **background** and the **source of the environment reflection**. */
#define SKY_W 256
#define SKY_H 64
int make_sky_tex(void) {
    int y; int x; int img;
    ui_2d_clear(0);
    for (y = 0; y < SKY_H; y = y + 1) {
        int c;
        if (y < SKY_H / 2) {
            float t;
            t = (float)y / (float)(SKY_H / 2);
            c = argb((int)(60.0f + 120.0f * t), (int)(110.0f + 90.0f * t), (int)(190.0f + 40.0f * t));
        } else {
            float t;
            t = (float)(y - SKY_H / 2) / (float)(SKY_H / 2);
            c = argb((int)(120.0f - 70.0f * t), (int)(120.0f - 66.0f * t), (int)(96.0f - 56.0f * t));
        }
        ui_2d_rect(0, y, SKY_W, 1, c, 1, 0, 0);
    }
    /* 太阳:一个亮块 + 一圈光晕(放在 u=0.25 处,与主光方向大致对得上)。 */
    /* The sun: a bright core plus a halo, at u = 0.25, roughly matching the key light. */
    for (x = 52; x < 76; x = x + 1) {
        for (y = 8; y < 26; y = y + 1) {
            int dx; int dy; int d2;
            dx = x - 64; dy = y - 16;
            d2 = dx * dx + dy * dy;
            if (d2 < 36) ui_2d_rect(x, y, 1, 1, argb(255, 246, 210), 1, 0, 0);
            else if (d2 < 160) ui_2d_rect(x, y, 1, 1, argb(228, 224, 186), 1, 0, 0);
        }
    }
    ui_2d_present();
    img = ui_2d_get_image(0, 0, SKY_W, SKY_H);
    ui_2d_clear(0);
    ui_2d_present();
    return img;
}

/* ══ 灯光(NLIGHT 盏,逐盏都要有可见作用)═══════════════════════════════════════ */
/* == Lights (NLIGHT of them, every one must visibly matter) == */
float L_DX[NLIGHT]; float L_DY[NLIGHT]; float L_DZ[NLIGHT];
int   L_COL[NLIGHT]; float L_AMB[NLIGHT];

void set_light(int vp, int i) {
    ui_3d_viewport_set_light_at(vp, i, L_DX[i], L_DY[i], L_DZ[i], L_COL[i], L_AMB[i]);
}

void init_lights(void) {
    /* ⚠ 五盏灯是**叠着加**的:每盏都给满强度时,向光面直接烧成一片惨白(实测第一版就是这样,
     * ⚠ Five lights **add up**: at full strength every lit surface clips to white (the first version
     *   地面成了没有细节的薄荷绿)。所以主光只给 0.6 左右,其余是**补光**级别的小量。
     *   did exactly that). The key light gets about 0.6 and the rest stay at fill-light levels. */
    /* 主光(太阳,唯一投影的那盏)。 */
    /* the key light: the sun, and the only one that casts shadows. */
    L_DX[0] = 0.34f; L_DY[0] = 0.86f; L_DZ[0] = 0.38f;
    L_COL[0] = argb(168, 157, 139); L_AMB[0] = 0.10f;
    /* 天光(冷色补光)。 */
    /* sky fill, a cool colour. */
    L_DX[1] = -0.52f; L_DY[1] = 0.46f; L_DZ[1] = -0.72f;
    L_COL[1] = argb(62, 86, 124); L_AMB[1] = 0.06f;
    /* 暖色低角度反弹光。 */
    /* a warm, low-angle bounce. */
    L_DX[2] = 0.68f; L_DY[2] = 0.30f; L_DZ[2] = -0.66f;
    L_COL[2] = argb(106, 76, 50); L_AMB[2] = 0.03f;
    /* 冷青轮廓光。 */
    /* a cool cyan rim light. */
    L_DX[3] = -0.40f; L_DY[3] = 0.58f; L_DZ[3] = 0.72f;
    L_COL[3] = argb(48, 96, 94); L_AMB[3] = 0.03f;
    /* 紫色点缀光。 */
    /* a violet accent light. */
    L_DX[4] = -0.74f; L_DY[4] = 0.24f; L_DZ[4] = -0.62f;
    L_COL[4] = argb(76, 52, 100); L_AMB[4] = 0.02f;
}

/* ══ 游戏状态 ══════════════════════════════════════════════════════════════ */
/* == Game state == */
/* 主视口。 */
/* the main viewport. */
int vp;
/* 小地图视口(正交)。 */
/* the minimap viewport (orthographic). */
int vpmini;
int terrain_mesh; int terrain_obj; int terrain_mini;
int rock_mesh; int sphere_mesh; int box_mesh; int marker_mesh;
int rock_obj[NROCK];
int metal_obj[NMETAL]; int ped_obj[NMETAL];
int car_scene[3]; int car_root[3];
int fruit_scene[NF]; int fruit_root[NF];
int mini_mark[NF]; int mini_player;
int cut_scene;
int mini_w; int mini_h; int scr_w; int scr_h;
int ground_tex; int sky_tex;
/* 判据用的第二张天空盒(纯洋红)。 */
/* the second skybox used by the criteria (solid magenta). */
int g_magenta;

float p_x; float p_z; float p_y; float p_head; float p_speed;
float f_x[NF]; float f_z[NF];
int f_taken[NF];
int score;
/* 0 = 进行中 / 1 = 通关 / 2 = 超时。 */
/* 0 = playing / 1 = won / 2 = out of time. */
int state;
float time_left;
int frame;
int last_ms;
/* 输入 —— 键盘与自动驾驶写的是**同一对变量**。 */
/* input: the keys and the autopilot write the very same pair. */
float in_thr; float in_turn;

/* 金属球的位置 —— 摆位与判据都要它,所以**放在判据段之前**。 */
/* Metal-sphere placement, needed by both the setup and the criteria, so it lives before them. */
float m_x[NMETAL]; float m_y[NMETAL]; float m_z[NMETAL];

/* ══ 建场景 ═══════════════════════════════════════════════════════════════ */
/* == Build the world == */
float b4[4];

int load_first(int vp_, const char** list, int n, const char** used) {
    int i; int sc;
    sc = -1;
    for (i = 0; i < n; i = i + 1) {
        sc = ui_3d_gltf_load(vp_, list[i]);
        if (sc > 0) { *used = list[i]; break; }
    }
    return sc;
}

const char* kCarPaths[3] = {
    "examples/c/3d/ToyCar.glb", "ToyCar.glb", "models/ToyCar.glb",
};
const char* kFruitPaths[3] = {
    "examples/c/3d/Avocado.glb", "Avocado.glb", "models/Avocado.glb",
};
const char* kCutPaths[3] = {
    "examples/c/3d/rover_cutout.glb", "rover_cutout.glb", "models/rover_cutout.glb",
};

/* 第 i 棵树的世界坐标 —— 公式与 `scripts/make-rover-cutout-glb.py` 里那份**逐项相同**
 * The i-th tree's world position; the formula is term for term the one in the generator script,
 * (两处漂了就会出现"车从树干里穿过去"或"果子长在树里够不着")。
 * and a drift between them shows up as driving through a trunk or an unreachable fruit. */
void tree_pos(int i, float* ox, float* oz) {
    float th; float rr;
    th = 6.28318531f * (float)(i + 0.5f) / (float)NTREE;
    rr = TREE_RR0 + TREE_RR1 * (float)((i * 7919) % 100) / 100.0f;
    *ox = rr * cos((double)th);
    *oz = rr * sin((double)th);
}

/* 果子落点:**摊在场地上**,并躲开树干与金属球(不然会出现够不着的果子)。 */
/* Fruit spots, spread out but clear of trunks and metal spheres so every fruit is reachable. */
/* Fruit spots, spread out and non-overlapping, from a fixed LCG so runs are reproducible. */
void place_fruits_spots(void) {
    int i;
    for (i = 0; i < NF; i = i + 1) {
        float th; float rr; int k; int bad;
        th = 6.28318531f * (float)i / (float)NF + 0.35f;
        rr = 14.0f + 12.0f * ((float)(i % 3) / 2.0f);
        f_x[i] = rr * cos((double)th);
        f_z[i] = rr * sin((double)th);
        for (k = 0; k < 24; k = k + 1) {
            int t;
            bad = 0;
            for (t = 0; t < NTREE; t = t + 1) {
                float tx; float tz; float dx; float dz;
                tree_pos(t, &tx, &tz);
                dx = tx - f_x[i]; dz = tz - f_z[i];
                if (dx * dx + dz * dz < 49.0f) bad = 1;
            }
            if (bad == 0) break;
            /* 往外挪一点再试。 */
            /* nudge outward and retry. */
            rr = rr + 1.4f;
            /* 不许被挪到栅栏外面去。 */
            /* never past the fence. */
            if (rr > 37.0f) break;
            f_x[i] = rr * cos((double)th);
            f_z[i] = rr * sin((double)th);
        }
    }
}

void reset_game(void) {
    int i;
    p_x = 0.0f; p_z = -2.0f; p_head = 0.0f; p_speed = 0.0f;
    go_prog = 0; go_escape = 0; go_lastd = 9999.0f;
    p_y = terr_h(p_x, p_z);
    for (i = 0; i < NF; i = i + 1) {
        f_taken[i] = 0;
        ui_3d_obj_set_visible(fruit_root[i], 1);
        ui_3d_obj_set_visible(mini_mark[i], 1);
    }
    score = 0;
    state = 0;
    time_left = ROUND_SEC;
    in_thr = 0.0f; in_turn = 0.0f;
}

void place_car(void) {
    p_y = terr_h(p_x, p_z);
    ui_3d_obj_set_pos(car_root[0], p_x, p_y + CAR_LIFT, p_z);
    ui_3d_obj_set_rot(car_root[0], 0.0f, p_head * 57.29578f, 0.0f);
}

/* 一步游戏:**按墙钟的秒数**推进(帧率不该是玩法的一部分)。
 * One game step, advanced by **wall-clock seconds** (frame rate is not part of the gameplay). */
void step_game(float dt) {
    int i;
    if (state != 0) return;
    time_left = time_left - dt;
    p_speed = p_speed + in_thr * ACC * dt;
    if (p_speed > VMAX) p_speed = VMAX;
    if (p_speed < -VMAX * 0.55f) p_speed = -VMAX * 0.55f;
    p_speed = p_speed - p_speed * DRAG * dt;
    if (p_speed > -0.05f && p_speed < 0.05f && in_thr == 0.0f) p_speed = 0.0f;
    p_head = p_head + in_turn * TURN_RATE * 0.01745f * dt * (p_speed >= 0.0f ? 1.0f : -1.0f);
    p_x = p_x + sin((double)p_head) * p_speed * dt;
    p_z = p_z + cos((double)p_head) * p_speed * dt;
    {
        float d;
        d = sqrt((double)(p_x * p_x + p_z * p_z));
        if (d > ARENA) { p_x = p_x * ARENA / d; p_z = p_z * ARENA / d; p_speed = p_speed * 0.4f; }
    }
    /* 树干与金属球**推不开**:本作不做物理,只保证镜头不会钻进模型里。
     * Trunks and metal spheres are immovable; there is no physics here, only a push-out so the
     * 少了这一段,车会开进金属球内部,第三人称相机会紧贴球面(实测画面被一个白球占满)。
     * camera cannot end up inside a sphere (measured: one white ball filled the whole frame). */
    {
        int i; float dx; float dz; float dd;
        for (i = 0; i < NTREE; i = i + 1) {
            float tx; float tz;
            tree_pos(i, &tx, &tz);
            dx = p_x - tx; dz = p_z - tz;
            dd = sqrt((double)(dx * dx + dz * dz));
            if (dd < BODY_R) {
                if (dd < 0.01f) { dx = 1.0f; dz = 0.0f; dd = 1.0f; }
                p_x = tx + dx * BODY_R / dd;
                p_z = tz + dz * BODY_R / dd;
                p_speed = p_speed * 0.25f;
            }
        }
        for (i = 0; i < NMETAL; i = i + 1) {
            dx = p_x - m_x[i]; dz = p_z - m_z[i];
            dd = sqrt((double)(dx * dx + dz * dz));
            if (dd < METAL_R) {
                if (dd < 0.01f) { dx = 1.0f; dz = 0.0f; dd = 1.0f; }
                p_x = m_x[i] + dx * METAL_R / dd;
                p_z = m_z[i] + dz * METAL_R / dd;
                p_speed = p_speed * 0.25f;
            }
        }
    }
    place_car();
    for (i = 0; i < NF; i = i + 1) {
        float dx; float dz; float dd;
        if (f_taken[i]) continue;
        dx = f_x[i] - p_x; dz = f_z[i] - p_z;
        dd = sqrt((double)(dx * dx + dz * dz));
        if (dd < COLLECT_R) {
            f_taken[i] = 1;
            score = score + 1;
            ui_3d_obj_set_visible(fruit_root[i], 0);
            ui_3d_obj_set_visible(mini_mark[i], 0);
        }
    }
    if (score >= NF) state = 1;
    else if (time_left <= 0.0f) { time_left = 0.0f; state = 2; }
}

/* 自动驾驶:朝最近的、还没收的果子开 —— **写的正是键盘写的那两个变量**,
 * Autopilot: steer at the nearest uncollected fruit; it writes **the very variables the keys write**,
 * 所以"自己开"和"自己玩"走的是同一条移动/收集代码。
 * so the self-drive and a human drive share one movement/collection code path. */
/* "离目标还没更近"累计了几帧,以及"倒车摆脱"还欠多少帧。 */
/* Frames without getting closer to the target, and how many escape frames are still owed. */
int go_prog;
int go_escape;
float go_lastd;

/* 一个障碍对方向盘的偏置:在正前方(±0.9 弧度内)才管,越近权重越大。
 * One obstacle's steering bias: only if it is dead ahead (within +-0.9 rad), weighted by closeness.
 * 偏置符号 = "往哪边躲"(与角差同号约定:角差为正 ⇒ 增大朝向角)。 */
/* The sign says which way to dodge, in the same convention as the heading difference. */
float avoid_one(float ox, float oz) {
    float dx; float dz; float dd; float rel;
    dx = ox - p_x; dz = oz - p_z;
    dd = sqrt((double)(dx * dx + dz * dz));
    if (dd > 10.0f || dd < 0.01f) return 0.0f;
    rel = atan2((double)dx, (double)dz) - (double)p_head;
    while (rel > 3.14159265) rel = rel - 6.28318531f;
    while (rel < -3.14159265) rel = rel + 6.28318531f;
    if (rel < 0.0f) { if (rel < -0.9f) return 0.0f; }
    else { if (rel > 0.9f) return 0.0f; }
    return (rel > 0.0f) ? (0.0f - (10.0f - dd)) : (10.0f - dd);
}

void autopilot(void) {
    int i; int best; float bd; float want; float diff;
    /* 撞上树干/金属球时先倒车打方向 —— 纯"朝目标直冲"会把自己顶在障碍上不动。 */
    /* On hitting a trunk or sphere, back up and steer: driving straight at the target gets stuck. */
    if (go_escape > 0) {
        go_escape = go_escape - 1;
        in_thr = -1.0f;
        in_turn = 1.0f;
        return;
    }
    best = -1; bd = 0.0f;
    for (i = 0; i < NF; i = i + 1) {
        float dx; float dz; float dd;
        if (f_taken[i]) continue;
        dx = f_x[i] - p_x; dz = f_z[i] - p_z;
        dd = sqrt((double)(dx * dx + dz * dz));
        if (best < 0 || dd < bd) { best = i; bd = dd; }
    }
    if (best < 0) { in_thr = 0.0f; in_turn = 0.0f; return; }
    /* ⚠ 不要用"速度小"当卡住判据:起步那几帧速度本来就是 0,会把正常起步也判成卡住。
     * ⚠ Do not use "speed is low" as the stuck test: the car starts at zero speed, so every take-off
     *   判据是"离目标**没有更近**"连续够多帧(与速度、方向都无关)。 */
    /*   looks stuck. Use "has not got closer in N frames" instead - speed- and heading-agnostic. */
    if (bd < go_lastd - 0.3f) { go_prog = 0; go_lastd = bd; }
    else go_prog = go_prog + 1;
    if (go_prog > 45) {
        go_prog = 0;
        go_lastd = bd;
        go_escape = 34;
        in_thr = -1.0f;
        in_turn = 1.0f;
        return;
    }
    want = atan2((double)(f_x[best] - p_x), (double)(f_z[best] - p_z));
    diff = want - (double)p_head;
    while (diff > 3.14159265) diff = diff - 6.28318531f;
    while (diff < -3.14159265) diff = diff + 6.28318531f;
    if (diff > 0.12f) in_turn = 1.0f;
    else if (diff < -0.12f) in_turn = -1.0f;
    else in_turn = 0.0f;
    /* ⚠ 油门要**随距离收**:方向盘打到底也有个最小转弯半径(r ≈ v / ω),
     * ⚠ Ease the throttle with distance: even at full lock the car has a minimum turn radius
     *   全速冲过去会绕着果子转圈、永远进不了收果半径(实测:660 帧只收到 3 个)。
     *   (r = v / omega), so charging in at top speed orbits the fruit forever (measured: 3 of 8).
     *   近了就慢下来,转弯半径跟着变小,才收得进去。 */
    /*   Slow down up close: the radius shrinks with speed and the car spirals in. */
    in_thr = bd / 22.0f;
    if (in_thr > 1.0f) in_thr = 1.0f;
    if (in_thr < 0.22f) in_thr = 0.22f;

    /* 避障:正前方有树干/金属球就把方向盘掰开。少了这一段,车会顶着障碍不动,
     * Obstacle avoidance: steer away from a trunk or sphere dead ahead. Without it the car presses
     * 而"直冲目标 + 固定倒车摆脱"会在同一个点上来回死循环(实测 660 帧只收到 2 个)。
     * into the obstacle, and "drive straight plus a fixed reverse" loops forever (measured: 2 of 8). */
    {
        int t; float bias; float lx; float lz;
        bias = 0.0f;
        for (t = 0; t < NTREE; t = t + 1) {
            tree_pos(t, &lx, &lz);
            bias = bias + avoid_one(lx, lz);
        }
        for (t = 0; t < NMETAL; t = t + 1) {
            bias = bias + avoid_one(m_x[t], m_z[t]);
        }
        if (bias > 0.01f) in_turn = 1.0f;
        else if (bias < -0.01f) in_turn = -1.0f;
    }
}

/* ══ 相机 ══════════════════════════════════════════════════════════════════ */
/* == Camera == */
/* 当前主相机 —— 判据改过它,之后要还原。 */
/* the current main camera; the criteria move it and must put it back. */
float cam_x; float cam_y; float cam_z;

void set_view(float ex, float ey, float ez, float tx, float ty, float tz) {
    cam_x = ex; cam_y = ey; cam_z = ez;
    ui_3d_viewport_set_camera(vp, ex, ey, ez, tx, ty, tz);
}

/* 第三人称:相机在车后上方,始终看着车前方一点。 */
/* Third person: the camera sits behind and above the car, always looking a little ahead of it. */
void game_view(void) {
    float bx; float bz; float tx; float tz;
    bx = p_x - sin((double)p_head) * 10.5f;
    bz = p_z - cos((double)p_head) * 10.5f;
    tx = p_x + sin((double)p_head) * 10.0f;
    tz = p_z + cos((double)p_head) * 10.0f;
    set_view(bx, terr_h(bx, bz) + 5.8f, bz, tx, p_y + CAR_LIFT - 0.6f, tz);
}

void restore_view(void) { set_view(cam_x, cam_y, cam_z, cam_x, cam_y, cam_z); }

/* ══ 采样判据用的工具 ═════════════════════════════════════════════════════ */
/* == Sampling helpers for the criteria == */
#define SGX 33
#define SGY 21
int g_samp[SGX * SGY];
int g_base[SGX * SGY];

/* 把整个视口按 SGX×SGY 打一遍,存**亮度**(判据只需要"变了没有、往哪边变")。 */
/* Sample the whole viewport on an SGX x SGY grid, storing **luminance**. */
void grab(int vp_, int w, int h, int* dst) {
    int x; int y;
    for (y = 0; y < SGY; y = y + 1) {
        for (x = 0; x < SGX; x = x + 1) {
            int px; int py;
            px = (w - 1) * x / (SGX - 1);
            py = (h - 1) * y / (SGY - 1);
            dst[y * SGX + x] = lum(ui_3d_viewport_get_pixel(vp_, px, py));
        }
    }
}

/* 两遍采样里有几格**变暗了**至少 `k`(阴影判据用它)。 */
/* How many samples got **darker** by at least `k` between two passes. */
int count_darker(int* a, int* b, int k) {
    int i; int n;
    n = 0;
    for (i = 0; i < SGX * SGY; i = i + 1) if (a[i] - b[i] >= k) n = n + 1;
    return n;
}

/* 两遍采样里最大的单格差("这一盏灯有没有可见作用"用它)。 */
/* The largest single-sample gap between two passes (used for "does this light matter"). */
int max_delta(int* a, int* b) {
    int i; int m; int d;
    m = 0;
    for (i = 0; i < SGX * SGY; i = i + 1) {
        d = a[i] - b[i]; if (d < 0) d = 0 - d;
        if (d > m) m = d;
    }
    return m;
}

/* 一个矩形区域里"暗格"的个数(镂空判据用它:缝被丢弃之后暗格应当少掉)。 */
/* How many dark cells a rectangle holds (the cut-out criterion: discarding the holes removes them). */
int count_dark(int vp_, int x0, int y0, int x1, int y1, int thr) {
    int x; int y; int n;
    n = 0;
    for (y = y0; y < y1; y = y + 8) {
        for (x = x0; x < x1; x = x + 8) {
            if (lum(ui_3d_viewport_get_pixel(vp_, x, y)) < thr) n = n + 1;
        }
    }
    return n;
}

/* 一片小区域里亮度的极差(mipmap 判据用它:缩小时高频会塌下去)。 */
/* Luminance spread over a small patch (the mipmap criterion: minification collapses it). */
int spread_patch(int vp_, int cx, int cy, int hw, int hh) {
    int x; int y; int lo; int hi;
    lo = 9999; hi = -1;
    for (y = cy - hh; y <= cy + hh; y = y + 2) {
        for (x = cx - hw; x <= cx + hw; x = x + 2) {
            int c;
            c = lum(ui_3d_viewport_get_pixel(vp_, x, y));
            if (c < lo) lo = c;
            if (c > hi) hi = c;
        }
    }
    return hi - lo;
}

/* 一片区域的**粗糙度** = 相邻两列亮度差的平均。mipmap 抹掉的正是这个量;
 * A patch's roughness: the mean absolute difference between neighbouring columns. This is exactly
 * ⚠ 拿"极差"当判据是不行的:地形的**大尺度明暗**(山坡背光)也让极差很大,mipmap 消不掉它。
 * what mipmap removes; a max-minus-min reading stays large because of large-scale hill shading.
 */
int rough_patch(int vp_, int cx, int cy, int hw, int hh) {
    int x; int y; int t; int n;
    t = 0; n = 0;
    for (y = cy - hh; y <= cy + hh; y = y + 2) {
        for (x = cx - hw; x <= cx + hw - 4; x = x + 2) {
            int a; int b; int d;
            a = lum(ui_3d_viewport_get_pixel(vp_, x, y));
            b = lum(ui_3d_viewport_get_pixel(vp_, x + 4, y));
            d = a - b; if (d < 0) d = 0 - d;
            t = t + d; n = n + 1;
        }
    }
    if (n == 0) return 0;
    return t / n;
}

/* 一小块区域里"接近某个颜色"的格数(正交判据用它数标记有多大)。 */
/* How many cells in a small region are close to a colour (the ortho criterion measures marker size). */
int count_near(int vp_, int cx, int cy, int hw, int r, int g, int b, int tol) {
    int n; int dx; int dy;
    n = 0;
    for (dy = 0 - hw; dy <= hw; dy = dy + 1) {
        for (dx = 0 - hw; dx <= hw; dx = dx + 1) {
            int c;
            c = ui_3d_viewport_get_pixel(vp_, cx + dx, cy + dy);
            if (rch(c) - r < tol && r - rch(c) < tol
                && gch(c) - g < tol && g - gch(c) < tol
                && bch(c) - b < tol && b - bch(c) < tol) n = n + 1;
        }
    }
    return n;
}

/* ══ 判据 ══════════════════════════════════════════════════════════════════ */
/* == Criteria == */
void check_world(void) {
    int i; int cars; int fruits; int objs;
    cars = 0; fruits = 0;
    for (i = 0; i < 3; i = i + 1) if (car_scene[i] > 0) cars = cars + 1;
    for (i = 0; i < NF; i = i + 1) if (fruit_scene[i] > 0) fruits = fruits + 1;
    printf("WORLD cars=%d/%d fruits=%d/%d cutout=%d\n", cars, 3, fruits, NF, cut_scene);
    ck("glTF: all three real/companion models loaded", cars == 3 && fruits == NF && cut_scene > 0,
       cars + fruits + (cut_scene > 0 ? 1 : 0), 3 + NF + 1);

    /* 真模型的几何:ToyCar 15 个物体、Avocado 3 个 —— 它们**是数据**,不是写死的常量。 */
    /* Real-model geometry: ToyCar gives 15 objects and Avocado 3 - data, not a hard-coded constant. */
    printf("GEOM car_scene_objs=%d fruit_scene_objs=%d\n",
           ui_3d_gltf_object_count(car_scene[0]), ui_3d_gltf_object_count(fruit_scene[0]));
    ck("glTF: the car scene expanded into 15 objects (node tree, not one blob)",
       ui_3d_gltf_object_count(car_scene[0]) == 15, ui_3d_gltf_object_count(car_scene[0]), 15);
    ck("glTF: the fruit scene expanded into 3 objects",
       ui_3d_gltf_object_count(fruit_scene[0]) == 3, ui_3d_gltf_object_count(fruit_scene[0]), 3);
    b4[0] = 0.0f; b4[1] = 0.0f; b4[2] = 0.0f; b4[3] = 0.0f;
    ui_3d_gltf_bounds(car_scene[0], (int)&b4[0]);
    {
        float rb;
        rb = b4[3];
        b4[0] = 0.0f; b4[1] = 0.0f; b4[2] = 0.0f; b4[3] = 0.0f;
        ui_3d_gltf_bounds(fruit_scene[0], (int)&b4[0]);
        printf("BOUNDS car_r=%.4f fruit_r=%.4f\n", rb, b4[3]);
        ck("both downloaded models carry real geometry (positive bounds)",
           rb > 0.0f && b4[3] > 0.0f, (int)(rb * 10000.0f), 1);
    }

    /* 玩家开的车**必须在画面里** —— 这是"真模型当主角"这条要求的落点。 */
    /* The car the player drives must be **on screen**: that is where "a real model is the hero" lands. */
    game_view();
    ui_2d_present();
    {
        int sc; int px; int py; int a; int b; int k; int cn;
        sc = ui_3d_obj_get_screen(car_root[0]);
        px = sc & 65535; py = (sc >> 16) & 65535;
        /* ⚠ **可见性是逐物体的、不沿父子链继承** —— 只把根藏起来,它的孩子照画不误
         * ⚠ Visibility is **per object and is NOT inherited** - hiding only the root leaves every
         *   (第一版就是这么写的,于是"藏了跟没藏一样",判据恒假红)。
         *   child drawing (the first version did that and the pixel never changed). */
        cn = ui_3d_gltf_object_count(car_scene[0]);
        for (k = 0; k < cn; k = k + 1) {
            ui_3d_obj_set_visible(ui_3d_gltf_object_at(car_scene[0], k), 0);
        }
        ui_2d_present();
        a = ui_3d_viewport_get_pixel(vp, px, py);
        for (k = 0; k < cn; k = k + 1) {
            ui_3d_obj_set_visible(ui_3d_gltf_object_at(car_scene[0], k), 1);
        }
        ui_2d_present();
        b = ui_3d_viewport_get_pixel(vp, px, py);
        printf("CAR screen=%d,%d hidden=%d shown=%d\n", px, py, a, b);
        ck("the downloaded car really renders on screen (it is the player)",
           a != b && px > 0 && px < scr_w && py > 0 && py < scr_h, px, py);
    }
    objs = g_objects;
    printf("OBJECTS=%d\n", objs);
    ck("object count is well past 256 (that is why the cap was raised)",
       objs > 256, objs, 256);
}

void check_gamma(void) {
    int i; int d_off; int d_srgb;
    /* 基线 = **当前就有**的 ACES(`main` 里已经设上了)。 */
    /* The baseline is the ACES already in force from main. */
    game_view();
    ui_2d_present();
    grab(vp, scr_w, scr_h, g_base);
    i = ui_3d_viewport_set_gamma(vp, 0);
    ui_2d_present();
    grab(vp, scr_w, scr_h, g_samp);
    d_off = max_delta(g_base, g_samp);
    printf("GAMMA aces_vs_off_maxdelta=%d\n", d_off);
    ck("output transform (ACES+sRGB) really changes the picture", d_off >= 12, d_off, 12);
    ui_3d_viewport_set_gamma(vp, 1);
    ui_2d_present();
    grab(vp, scr_w, scr_h, g_samp);
    d_srgb = max_delta(g_base, g_samp);
    printf("GAMMA aces_vs_srgb_maxdelta=%d\n", d_srgb);
    ck("ACES is not just sRGB (a real tone curve sits in front of it)", d_srgb >= 10, d_srgb, 10);
    ui_3d_viewport_set_gamma(vp, 3);
    ui_2d_present();
    grab(vp, scr_w, scr_h, g_samp);
    ck("back to ACES -> bit-identical", max_delta(g_base, g_samp) == 0 && i == 0,
       max_delta(g_base, g_samp), 0);
}

void check_lights(void) {
    int i; int j; int md; int back_ok; int bad;
    bad = 0;
    game_view();
    for (i = 0; i < NLIGHT; i = i + 1) {
        set_light(vp, i);
        ui_2d_present();
        grab(vp, scr_w, scr_h, g_base);
        ui_3d_viewport_set_light_at(vp, i, L_DX[i], L_DY[i], L_DZ[i], 0, 0.0f);
        ui_2d_present();
        grab(vp, scr_w, scr_h, g_samp);
        md = max_delta(g_base, g_samp);
        set_light(vp, i);
        ui_2d_present();
        grab(vp, scr_w, scr_h, g_samp);
        back_ok = (max_delta(g_base, g_samp) == 0);
        printf("LIGHT[%d] maxdelta=%d restore_bit_identical=%d\n", i, md, back_ok);
        if (!(md >= 5 && back_ok)) bad = bad + 1;
    }
    ck("all five lights visibly matter (and restore bit-identically)", bad == 0, bad, 0);
    j = ui_3d_viewport_set_light_at(vp, 9, 0.0f, 1.0f, 0.0f, C_WHITE, 0.0f);
    ck("light index out of range is rejected", j < 0, j, -2);
}

/* 阴影:影子的判据挑的是"**起伏地形**上那一片" —— 平面投影(`#642`)只会把物体压到
 * Shadows: the criterion samples a patch on the **rolling** terrain, which the planar path (#642)
 * `y = 0` 那一层上,够不着山坡 ⇒ 两条路能分开量。
 * can only reach at y = 0, so the two paths separate cleanly. */
int g_shadowA[SGX * SGY];
int g_shadowB[SGX * SGY];
int g_mark;
int g_mark_end;

/* 把物体**沿光线**推进,直到穿过地形 —— 影子的落点就是这么算出来的(不是"哪块变暗了"猜的)。
 * March from the caster along the light until it crosses the terrain; that is the landing point. */
void shadow_landing(float cx, float cy, float cz, float lx, float ly, float lz,
                    float* ox, float* oz) {
    float n; float t; float px; float py; float pz;
    n = sqrt((double)(lx * lx + ly * ly + lz * lz));
    lx = lx / n; ly = ly / n; lz = lz / n;
    px = cx; py = cy; pz = cz;
    t = 0.0f;
    while (t < 240.0f) {
        if (py <= terr_h(px, pz)) break;
        t = t + 0.2f;
        px = cx - lx * t;
        py = cy - ly * t;
        pz = cz - lz * t;
    }
    *ox = px;
    *oz = pz;
}

/* 一个"不可见的方向标":摆在世界某点、读它的屏幕坐标、然后藏起来。
 * An invisible probe: park it at a world point, read its screen position, then hide it. */
void mark_at(float x, float y, float z) {
    ui_3d_obj_set_pos(g_mark, x, y, z);
    ui_3d_obj_set_visible(g_mark, 1);
    ui_2d_present();
    g_mark_end = ui_3d_obj_get_screen(g_mark);
    ui_3d_obj_set_visible(g_mark, 0);
    ui_2d_present();
}

void check_shadow(void) {
    int i; int pick; float best; float lx; float ly; float lz;
    float sx; float sz; float sx2; float sz2;
    int px; int py; int px2; int py2;
    int base1; int map1; int plan1; int base2; int map2; int old2;

    /* 找一个**影子落在明显抬起的地面上**的金属球 —— 抬起来才说明平面投影够不着。 */
    /* Pick the metal sphere whose shadow lands on clearly raised ground. */
    pick = 0; best = -99.0f;
    for (i = 0; i < NMETAL; i = i + 1) {
        float ox; float oz;
        shadow_landing(m_x[i], m_y[i] + 3.6f, m_z[i], L_DX[0], L_DY[0], L_DZ[0], &ox, &oz);
        if (terr_h(ox, oz) > best) { best = terr_h(ox, oz); pick = i; }
    }
    printf("SHADOW caster=%d landing_ground=%.2f\n", pick, (double)best);

    /* 相机站到**与光线方位垂直**的那一侧:主光与镜像光两处影子都能进画面。 */
    /* Stand perpendicular to the light's azimuth so both shadow spots are on screen. */
    set_view(m_x[pick] - 16.0f, m_y[pick] + 15.0f, m_z[pick] + 14.0f,
             m_x[pick], m_y[pick], m_z[pick]);
    ui_3d_viewport_set_light_at(vp, 0, L_DX[0], L_DY[0], L_DZ[0], L_COL[0], L_AMB[0]);
    ui_3d_viewport_set_shadow(vp, 0);
    ui_3d_viewport_set_shadow_map(vp, 0);

    lx = L_DX[0]; ly = L_DY[0]; lz = L_DZ[0];
    shadow_landing(m_x[pick], m_y[pick] + 3.6f, m_z[pick], lx, ly, lz, &sx, &sz);
    mark_at(sx, terr_h(sx, sz) + 0.25f, sz);
    px = g_mark_end & 65535; py = (g_mark_end >> 16) & 65535;
    base1 = ui_3d_viewport_get_pixel(vp, px, py);

    ui_3d_viewport_set_shadow(vp, 1);
    ui_2d_present();
    plan1 = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_shadow(vp, 0);
    ui_3d_viewport_set_shadow_map(vp, 1);
    ui_2d_present();
    map1 = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_shadow_map(vp, 0);
    ui_2d_present();
    printf("SHADOW at=(%.1f,%.1f) screen=%d,%d ground=%.2f base=%d planar=%d map=%d\n",
           (double)sx, (double)sz, px, py, (double)terr_h(sx, sz),
           lum(base1), lum(plan1), lum(map1));
    ck("the shadow map puts a shadow on the sloped landing point",
       lum(map1) < lum(base1) - 45, lum(map1), lum(base1));
    ck("the planar path cannot reach that slope (that pixel stays bit-identical)",
       plan1 == base1, plan1, base1);

    /* 光转到另一侧:**新的落点变暗、老的落点亮回来**(影子跟着光走)。 */
    /* Move the key light across: the new landing point darkens and the old one lights up. */
    ui_3d_viewport_set_light_at(vp, 0, 0.0f - lx, ly, 0.0f - lz, L_COL[0], L_AMB[0]);
    ui_3d_viewport_set_shadow_map(vp, 0);
    ui_2d_present();
    base2 = ui_3d_viewport_get_pixel(vp, px, py);
    shadow_landing(m_x[pick], m_y[pick] + 3.6f, m_z[pick], 0.0f - lx, ly, 0.0f - lz, &sx2, &sz2);
    mark_at(sx2, terr_h(sx2, sz2) + 0.25f, sz2);
    px2 = g_mark_end & 65535; py2 = (g_mark_end >> 16) & 65535;
    ui_3d_viewport_set_shadow_map(vp, 0);
    ui_2d_present();
    old2 = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_shadow_map(vp, 1);
    ui_2d_present();
    map2 = ui_3d_viewport_get_pixel(vp, px2, py2);
    printf("SHADOW moved new_at=(%.1f,%.1f) screen=%d,%d base=%d map=%d old_spot=%d\n",
           (double)sx2, (double)sz2, px2, py2, lum(base2), lum(map2), lum(old2));
    ck("the shadow follows the light (the old spot lights up, the new one darkens)",
       map2 != base2 && lum(old2) > lum(map1) + 30, lum(old2), lum(map1));
    ui_3d_viewport_set_light_at(vp, 0, L_DX[0], L_DY[0], L_DZ[0], L_COL[0], L_AMB[0]);
    ui_3d_viewport_set_shadow_map(vp, 0);
    ui_3d_viewport_set_shadow(vp, 0);
}

/* 镂空:把栅栏摆满画面,数"暗格" —— 缝被丢弃之后暗格应当**明显少掉**。
 * Cut-out: fill the view with the fence and count **dark** cells; discarding the holes must
 * ⚠ 关掉镂空时,缝里露出来的是贴图自己的 RGB(那两格画的是透明黑)⇒ 暗格变多。
 * cut that count appreciably. With the cutoff off the holes show the atlas RGB, which is black. */
void check_cutout(void) {
    int i; int n; int dark_on; int dark_off; int back;
    n = ui_3d_gltf_object_count(cut_scene);
    /* ⚠ 底色要临时**改成亮的**:镂空缝被丢弃之后露出来的是底色,只有底色亮,
     * ⚠ Brighten the clear colour for this check: with the holes discarded the clear colour shows
     *   才分得清"黑缝"(关着镂空)与"被丢掉"(开着镂空)—— 深色底时两者都是暗的,判据空转。
     *   through, which is the only way to tell a black hole from a discarded one. */
    ui_3d_viewport_set_bg(vp, argb(226, 214, 190));
    set_view(36.0f, terr_h(36.0f, 0.0f) + 4.0f, 0.0f,
             FENCE_R + 6.0f, terr_h(36.0f, 0.0f) + 4.0f, 0.0f);
    ui_2d_present();
    dark_on = count_dark(vp, 40, 60, scr_w - 40, scr_h - 60, 110);
    for (i = 0; i < n; i = i + 1) {
        ui_3d_obj_alpha_test(ui_3d_gltf_object_at(cut_scene, i), 0.0f);
    }
    ui_2d_present();
    dark_off = count_dark(vp, 40, 60, scr_w - 40, scr_h - 60, 110);
    printf("CUTOUT dark_with_cutoff=%d dark_without=%d objects=%d\n", dark_on, dark_off, n);
    ck("cut-out really discards (the holes are gone, the background shows through)",
       dark_off >= 200 && dark_on * 4 <= dark_off, dark_on, dark_off);
    for (i = 0; i < n; i = i + 1) {
        ui_3d_obj_alpha_test(ui_3d_gltf_object_at(cut_scene, i), 0.5f);
    }
    ui_2d_present();
    back = count_dark(vp, 40, 60, scr_w - 40, scr_h - 60, 110);
    ck("cutoff back on -> the count returns exactly", back == dark_on, back, dark_on);
    ui_3d_viewport_set_bg(vp, C_DARK);
}

/* mipmap:小地图是**整片地形缩到 104 像素**的俯视图 —— 每像素盖住好几个纹素,
 * Mipmap: the minimap squeezes the whole terrain into 104 pixels, so each pixel covers several
 * 没有 mip 就会闪成一片噪声(判据量的是亮度极差)。
 * texels; without mips it turns to noise, which the criterion measures as a luminance spread. */
void check_mipmap(void) {
    int i; int sp_nomip; int sp_mips; int sp_back;
    /* 先把小地图上的标记藏起来:果子标记是**自发光**的亮点,会把粗糙度读数抬起来。 */
    /* Hide the minimap markers first: the emissive dots would inflate the roughness reading. */
    for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], 0);
    ui_3d_obj_set_visible(mini_player, 0);
    ui_3d_obj_sampler(terrain_obj, 0, 0, 1, 1);
    ui_3d_obj_sampler(terrain_mini, 0, 0, 1, 1);
    ui_2d_present();
    sp_nomip = rough_patch(vpmini, mini_w / 2, mini_h / 2, 26, 22);
    ui_3d_obj_sampler(terrain_obj, 0, 0, 5, 1);
    ui_3d_obj_sampler(terrain_mini, 0, 0, 5, 1);
    ui_2d_present();
    sp_mips = rough_patch(vpmini, mini_w / 2, mini_h / 2, 26, 22);
    printf("MIPMAP rough_no_mip=%d rough_trilinear=%d\n", sp_nomip, sp_mips);
    ck("mipmap collapses the far-ground shimmer (trilinear vs point sampling)",
       sp_nomip >= 20 && sp_mips * 3 <= sp_nomip * 2, sp_mips, sp_nomip);
    ui_3d_obj_sampler(terrain_obj, 0, 0, 1, 1);
    ui_3d_obj_sampler(terrain_mini, 0, 0, 1, 1);
    ui_2d_present();
    sp_back = rough_patch(vpmini, mini_w / 2, mini_h / 2, 26, 22);
    ck("minFilter back to linear -> the roughness returns exactly", sp_back == sp_nomip, sp_back, sp_nomip);
    {
        int r;
        r = ui_3d_obj_sampler(terrain_obj, 0, 0, 7, 1);
        ck("out-of-range minFilter is rejected (not clamped)", r < 0, r, -2);
    }
    ui_3d_obj_sampler(terrain_obj, 0, 0, 5, 1);
    ui_3d_obj_sampler(terrain_mini, 0, 0, 5, 1);
    for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], f_taken[i] ? 0 : 1);
    ui_3d_obj_set_visible(mini_player, 1);
}

/* PBR 与环境反射:金属球**照得出天**是这一条的全部 —— 关掉 PBR 时天空盒只是背景,
 * PBR and environment reflection - the metal sphere **mirroring the sky** is the whole point.
 * 对球上的像素一个字节都不该动。
 * With PBR off the skybox is only a background and must not move one byte on that sphere. */
void check_pbr_env(void) {
    int i; int px; int py; int sc;
    int off_col; int noenv_col; int sky_col; int mag_col; int fixed1; int fixed2;
    /* 相机贴近金属球,让它占满画面中央 ⇒ 采样点必然落在球面上。 */
    /* Get close to the metal sphere so it fills the middle and the probe lands on it. */
    set_view(m_x[0] + 6.0f, m_y[0] + 5.2f, m_z[0] + 6.0f, m_x[0], m_y[0] + 3.6f, m_z[0]);
    ui_3d_viewport_set_pbr(vp, 1);
    ui_3d_viewport_set_skybox(vp, 0);
    for (i = 0; i < NMETAL; i = i + 1) ui_3d_obj_set_pbr(metal_obj[i], 1.0f, 0.12f);
    sc = ui_3d_obj_get_screen(metal_obj[0]);
    px = sc & 65535; py = (sc >> 16) & 65535;
    /* 先证明这一格**真的在球上**(把球藏掉,同一格必须变)—— 否则后面全是隔空比。 */
    /* First prove the probe is **on the sphere** (hide it and the pixel must change). */
    ui_3d_obj_set_visible(metal_obj[0], 0);
    ui_2d_present();
    off_col = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_obj_set_visible(metal_obj[0], 1);
    ui_2d_present();
    noenv_col = ui_3d_viewport_get_pixel(vp, px, py);
    ck("the probe really sits on the metal sphere", off_col != noenv_col, off_col, noenv_col);

    ui_3d_viewport_set_skybox(vp, sky_tex);
    ui_2d_present();
    sky_col = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_skybox(vp, g_magenta);
    ui_2d_present();
    mag_col = ui_3d_viewport_get_pixel(vp, px, py);
    printf("ENV no_env=%d,%d,%d sky=%d,%d,%d magenta_sky=%d,%d,%d\n",
           rch(noenv_col), gch(noenv_col), bch(noenv_col),
           rch(sky_col), gch(sky_col), bch(sky_col),
           rch(mag_col), gch(mag_col), bch(mag_col));
    ck("PBR makes the metal sphere reflect the environment (no env -> sky changes it)",
       sky_col != noenv_col, sky_col, noenv_col);
    /* ★ 反射必须是**有色**的(跟着天空走),不是"整体提亮成灰的"。 */
    /* ★ The reflection must be **coloured** by the sky, not a uniform grey lift. */
    ck("the reflection is tinted by the sky (a magenta sky gives a magenta sphere)",
       rch(mag_col) > gch(mag_col) + 40 && bch(mag_col) > gch(mag_col) + 40
       && bch(sky_col) >= rch(sky_col),
       gch(mag_col), rch(mag_col));

    /* 反向:**关掉 PBR** 时,换天空盒对球上的像素一个字节都不许动。 */
    /* Reverse: with PBR **off**, swapping the skybox must not touch the sphere at all. */
    ui_3d_viewport_set_pbr(vp, 0);
    ui_3d_viewport_set_skybox(vp, sky_tex);
    ui_2d_present();
    fixed1 = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_skybox(vp, g_magenta);
    ui_2d_present();
    fixed2 = ui_3d_viewport_get_pixel(vp, px, py);
    ck("skybox alone (PBR off) does not touch the sphere - it is only a background",
       fixed1 == fixed2, fixed1, fixed2);
    ui_3d_viewport_set_skybox(vp, sky_tex);
    ui_3d_viewport_set_pbr(vp, 1);
}

/* 正交:小地图下"离得远就变小"必须**不发生** —— 同一个标记挪远一倍,屏幕上的像素数不变;
 * Orthographic: on the minimap "farther means smaller" must **not** happen - the same marker
 * 换成透视立刻会变。
 * moved twice as far keeps its pixel count; switching to perspective changes it at once. */
void check_ortho(void) {
    int i; int near_n; int far_n; int p_near; int p_far;
    int cx; int cy; int ox; int oy; int sc;
    ox = scr_w - mini_w - 10;
    oy = 10;
    for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], 0);
    ui_3d_obj_set_visible(mini_player, 0);
    ui_3d_obj_set_visible(mini_mark[0], 1);
    /* 小地图相机临时摆成**斜视**:正交与透视的差别在正俯视下看不出来(远近一样)。 */
    /* Tilt the minimap camera for this check: straight down, ortho and perspective look alike. */
    ui_3d_viewport_set_camera(vpmini, 0.0f, 26.0f, -22.0f, 0.0f, 0.0f, 14.0f);
    ui_3d_viewport_set_fov(vpmini, 50.0f, 0.5f, 4000.0f);
    ui_3d_viewport_set_ortho(vpmini, 40.0f);
    ui_3d_obj_set_pos(mini_mark[0], 0.0f, terr_h(0.0f, 0.0f) + 4.0f, 0.0f);
    ui_2d_present();
    sc = ui_3d_obj_get_screen(mini_mark[0]);
    cx = (sc & 65535) - ox; cy = ((sc >> 16) & 65535) - oy;
    near_n = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
    /* 把标记挪到 40 单位以外(比相机到原点的距离还长),**大小必须不变** —— 这就是正交的签名。 */
    /* Move the marker 40 units away; its size must not change. That is the orthographic signature. */
    ui_3d_obj_set_pos(mini_mark[0], 0.0f, terr_h(0.0f, 40.0f) + 4.0f, 40.0f);
    ui_2d_present();
    sc = ui_3d_obj_get_screen(mini_mark[0]);
    cx = (sc & 65535) - ox; cy = ((sc >> 16) & 65535) - oy;
    far_n = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
    /* 换成透视:**标记不动**,同一格再量一次 —— 远的那一个立刻变小。 */
    /* Switch to perspective, leave the marker where it is and measure the same box again. */
    ui_3d_viewport_set_ortho(vpmini, 0.0f);
    ui_2d_present();
    p_far = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
    ui_3d_obj_set_pos(mini_mark[0], 0.0f, terr_h(0.0f, 0.0f) + 4.0f, 0.0f);
    ui_2d_present();
    sc = ui_3d_obj_get_screen(mini_mark[0]);
    cx = (sc & 65535) - ox; cy = ((sc >> 16) & 65535) - oy;
    p_near = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
    printf("ORTHO near=%d far=%d | perspective near=%d far=%d\n", near_n, far_n, p_near, p_far);
    ck("orthographic: the same marker keeps its size forty units farther away",
       near_n >= 20 && (near_n - far_n) * 100 <= near_n * 20, near_n, far_n);
    ck("switching to perspective makes the far marker smaller",
       p_far * 100 <= p_near * 65, p_far, p_near);
    /* 复位小地图:相机、正交半径、标记可见性。 */
    /* Restore the minimap: camera, ortho half-height, marker visibility. */
    ui_3d_viewport_set_ortho(vpmini, 52.0f);
    ui_3d_viewport_set_camera(vpmini, 0.0f, 120.0f, 0.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vpmini, 45.0f, 1.0f, 4000.0f);
    for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], f_taken[i] ? 0 : 1);
    ui_3d_obj_set_visible(mini_player, 1);
}

void check_rules(void) {
    int i; int s0; int moved;
    float x0; float z0;
    reset_game();
    place_car();
    /* ① 真的会动:踩满油门跑 0.5 秒(用固定 dt,不依赖真实帧率)。 */
    /* (1) It really moves: full throttle for 0.5 s of fixed dt, independent of the real frame rate. */
    x0 = p_x; z0 = p_z;
    for (i = 0; i < 30; i = i + 1) { in_thr = 1.0f; in_turn = 0.0f; step_game(0.0167f); }
    moved = (int)(sqrt((double)((p_x - x0) * (p_x - x0) + (p_z - z0) * (p_z - z0))) * 100.0f);
    printf("RULE moved_0.5s=%.2f units\n", (double)moved / 100.0);
    ck("the car really moves (throttle for half a second)", moved > 100, moved, 100);

    /* ② 收集:把车直接摆到果子上,走一步 ⇒ 记分 +1、果子消失、小地图标记也没了。 */
    /* (2) Collection: park the car on a fruit, step once: score +1, the fruit vanishes. */
    reset_game();
    p_x = f_x[0]; p_z = f_z[0]; p_head = 0.0f; p_speed = 0.0f;
    s0 = score;
    in_thr = 0.0f; in_turn = 0.0f;
    step_game(0.0167f);
    printf("RULE collect score %d -> %d taken=%d\n", s0, score, f_taken[0]);
    ck("driving onto a fruit scores a point and takes it off the field",
       score == s0 + 1 && f_taken[0] == 1, score, s0 + 1);

    /* ③ 不重复计分:还站在那儿再走十步,分数不许再动。 */
    /* (3) No double counting: stay there for ten more steps and the score must not move. */
    s0 = score;
    for (i = 0; i < 10; i = i + 1) step_game(0.0167f);
    ck("staying on the spot does not score twice", score == s0, score, s0);

    /* ④ 结束条件之一:把 8 个果子全收掉 ⇒ 通关。 */
    /* (4) End condition one: collect all 8 fruit -> win. */
    for (i = 0; i < NF; i = i + 1) {
        p_x = f_x[i]; p_z = f_z[i]; p_speed = 0.0f;
        step_game(0.0167f);
    }
    printf("RULE win score=%d state=%d\n", score, state);
    ck("collecting every fruit ends the round as a win", score == NF && state == 1, state, 1);

    /* ⑤ 结束条件之二:重新开一局,把时钟耗光 ⇒ 超时结束(而不是一直玩下去)。 */
    /* (5) End condition two: restart and burn the clock -> the round ends on time. */
    reset_game();
    for (i = 0; i < 200 && state == 0; i = i + 1) step_game(1.0f);
    printf("RULE timeout time_left=%.2f state=%d\n", (double)time_left, state);
    ck("running out of the clock ends the round", state == 2, state, 2);
    reset_game();
    place_car();
    game_view();
}

/* ══ 主程序 ═══════════════════════════════════════════════════════════════ */
/* == Main == */
void build_metal(int i) {
    float th;
    th = 6.28318531f * (float)i / (float)NMETAL + 0.2f;
    m_x[i] = 36.0f * cos((double)th);
    m_z[i] = 36.0f * sin((double)th);
    m_y[i] = terr_h(m_x[i], m_z[i]);
    ui_3d_obj_set_pos(ped_obj[i], m_x[i], m_y[i] + 1.1f, m_z[i]);
    ui_3d_obj_set_pos(metal_obj[i], m_x[i], m_y[i] + 3.6f, m_z[i]);
    ui_3d_obj_set_color(metal_obj[i], C_WHITE);
    ui_3d_obj_set_pbr(metal_obj[i], 1.0f, 0.12f);
    ui_3d_obj_set_material(metal_obj[i], argb(240, 240, 240), 200.0f);
}

int main(void) {
    int i;
    int ori;
    const char* used_car;
    const char* used_fruit;
    const char* used_cut;
    int gam;

    g_fails = 0;
    g_objects = 0;
    rnd = 20261008;

    ui_win_open_ex("3D 漫游寻果 Rover 3D", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    ori = ui_scr_get_orientation();
    scr_w = ui_scr_get_w_ex(ori, VML_CHROME_NONE);
    scr_h = ui_scr_get_h_ex(ori, VML_CHROME_NONE);
    if (scr_w <= 0) scr_w = 393;
    if (scr_h <= 0) scr_h = 757;
    printf("SCREEN=%dx%d\n", scr_w, scr_h);

    vp = ui_3d_viewport_create(0, 0, scr_w, scr_h, 0);
    if (vp <= 0) {
        /* 拿不到 3D 设备就**如实报**,不装作画出来了。 */
        /* No 3D device: say so out loud instead of pretending something was drawn. */
        printf("NO_3D_DEVICE=1 vp=%d\n", vp);
        return 0;
    }
    mini_w = 108;
    mini_h = 108;
    vpmini = ui_3d_viewport_create(scr_w - mini_w - 10, 10, mini_w, mini_h, 0);
    printf("VIEWPORTS main=%d mini=%d\n", vp, vpmini);

    ui_3d_viewport_set_bg(vp, C_DARK);
    ui_3d_viewport_set_depth(vp, 1);
    ui_3d_viewport_set_fov(vp, 52.0f, 0.5f, 3000.0f);
    init_lights();
    for (i = 0; i < NLIGHT; i = i + 1) set_light(vp, i);
    ui_3d_viewport_set_gamma(vp, 3);
    /* 雾只负责"远处淡一点",别把半场都糊掉(60/170 那一版把山脊都洗白了)。 */
    /* Fog is atmosphere only; the 60/170 pair washed out whole ridges. */
    ui_3d_viewport_set_fog(vp, argb(146, 162, 172), 95.0f, 230.0f);

    /* ── 贴图 ─────────────────────────────────────────────────────────────── */
    /* -- Textures -- */
    ground_tex = make_ground_tex();
    sky_tex = make_sky_tex();
    /* 判据用的第二张天空盒:**一片纯洋红** —— 金属球照出什么颜色,就是环境给的。 */
    /* The criteria's second skybox: solid magenta, so whatever the sphere mirrors came from the env. */
    ui_2d_clear(0);
    ui_2d_rect(0, 0, 16, 16, argb(255, 24, 236), 1, 0, 0);
    ui_2d_present();
    g_magenta = ui_2d_get_image(0, 0, 16, 16);
    ui_2d_clear(0);
    ui_2d_present();
    printf("TEXTURES ground=%d sky=%d magenta=%d\n", ground_tex, sky_tex, g_magenta);
    ui_3d_viewport_set_skybox(vp, sky_tex);

    /* ── 地形(共享给主视口与小地图)──────────────────────────────────────── */
    /* -- Terrain, shared by the main viewport and the minimap -- */
    gen_terrain(48, 52.0f, 24.0f);
    printf("TERRAIN verts=%d tris=%d floats_needed=%d cap=%d\n",
           g_vn, g_in / 3, g_vn * 8 + g_in, MB_CAP);
    ck("mesh buffer fits the terrain", g_vn * 8 + g_in <= MB_CAP, g_vn * 8 + g_in, MB_CAP);
    terrain_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    terrain_obj = ui_3d_obj_create(vp, terrain_mesh);
    terrain_mini = ui_3d_obj_create(vpmini, terrain_mesh);
    g_objects = g_objects + 2;
    ui_3d_obj_set_tex(terrain_obj, ground_tex);
    ui_3d_obj_set_tex(terrain_mini, ground_tex);
    /* 地面默认就用**三线性**(mipmap):远处不闪;`check_mipmap` 会把它拨回 1 再量一次。 */
    /* Trilinear (mipmap) by default so the far ground does not shimmer; check_mipmap toggles it. */
    ui_3d_obj_sampler(terrain_obj, 0, 0, 5, 1);
    ui_3d_obj_sampler(terrain_mini, 0, 0, 5, 1);
    ui_3d_obj_set_color(terrain_obj, C_WHITE);
    ui_3d_obj_set_color(terrain_mini, C_WHITE);
    ui_3d_obj_set_pbr(terrain_obj, 0.0f, 0.92f);
    ui_3d_obj_set_pbr(terrain_mini, 0.0f, 0.92f);

    /* ── 石头(物体数靠它们撑过 256)──────────────────────────────────────── */
    /* -- Rocks; these are what push the object count past 256 -- */
    gen_sphere(4, 6);
    rock_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    for (i = 0; i < NROCK; i = i + 1) {
        float rx; float rz; float s;
        rx = (frnd() - 0.5f) * 84.0f;
        rz = (frnd() - 0.5f) * 84.0f;
        if (rx * rx + rz * rz > FENCE_R * FENCE_R - 4.0f) { i = i - 1; continue; }
        rock_obj[i] = ui_3d_obj_create(vp, rock_mesh);
        g_objects = g_objects + 1;
        s = 0.22f + frnd() * 0.40f;
        ui_3d_obj_set_pos(rock_obj[i], rx, terr_h(rx, rz) + s * 0.45f, rz);
        ui_3d_obj_set_scale(rock_obj[i], s);
        ui_3d_obj_set_color(rock_obj[i], argb(132 + (int)(frnd() * 54.0f), 128 + (int)(frnd() * 50.0f),
                                              116 + (int)(frnd() * 44.0f)));
        ui_3d_obj_set_rot(rock_obj[i], frnd() * 360.0f, frnd() * 360.0f, frnd() * 360.0f);
        ui_3d_obj_set_pbr(rock_obj[i], 0.0f, 0.85f);
    }

    /* ── 金属球 + 石座(PBR / 环境反射 / 阴影的载体)───────────────────────── */
    /* -- Metal spheres on stone pedestals: the PBR / reflection / shadow carrier -- */
    gen_sphere(12, 16);
    sphere_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box(2.0f, 1.0f, 2.0f);
    box_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    for (i = 0; i < NMETAL; i = i + 1) {
        ped_obj[i] = ui_3d_obj_create(vp, box_mesh);
        metal_obj[i] = ui_3d_obj_create(vp, sphere_mesh);
        g_objects = g_objects + 2;
        ui_3d_obj_set_scale(ped_obj[i], 1.1f);
        ui_3d_obj_set_scale(metal_obj[i], 1.9f);
        ui_3d_obj_set_color(ped_obj[i], argb(168, 160, 148));
        ui_3d_obj_set_pbr(ped_obj[i], 0.0f, 0.7f);
        build_metal(i);
    }

    /* ── 小地图的标记(正交俯视;果子 + 玩家)────────────────────────────── */
    /* -- Minimap markers, seen under an orthographic top-down camera -- */
    gen_flat_quad(3.2f);
    marker_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    ui_3d_viewport_set_bg(vpmini, argb(14, 18, 14));
    ui_3d_viewport_set_depth(vpmini, 1);
    ui_3d_viewport_set_camera(vpmini, 0.0f, 120.0f, 0.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vpmini, 45.0f, 1.0f, 4000.0f);
    ui_3d_viewport_set_ortho(vpmini, 52.0f);
    for (i = 0; i < NLIGHT; i = i + 1) {
        ui_3d_viewport_set_light_at(vpmini, i, L_DX[i], L_DY[i], L_DZ[i], L_COL[i], 0.2f);
    }
    for (i = 0; i < NF; i = i + 1) {
        mini_mark[i] = ui_3d_obj_create(vpmini, marker_mesh);
        g_objects = g_objects + 1;
        ui_3d_obj_set_emissive(mini_mark[i], 1);
        ui_3d_obj_set_color(mini_mark[i], argb(255, 60, 60));
    }
    /* 主视口里也放一个同样的方片,当阴影判据的"不可见方向标"(平时藏着)。 */
    /* A twin quad in the main viewport serves as the shadow criteria's invisible probe. */
    g_mark = ui_3d_obj_create(vp, marker_mesh);
    g_objects = g_objects + 1;
    ui_3d_obj_set_visible(g_mark, 0);
    mini_player = ui_3d_obj_create(vpmini, marker_mesh);
    g_objects = g_objects + 1;
    ui_3d_obj_set_emissive(mini_player, 1);
    ui_3d_obj_set_color(mini_player, argb(250, 232, 96));

    /* ── glTF:真模型 ────────────────────────────────────────────────────── */
    /* -- glTF: the real models -- */
    used_car = ""; used_fruit = ""; used_cut = "";
    for (i = 0; i < 3; i = i + 1) {
        car_scene[i] = load_first(vp, kCarPaths, 3, &used_car);
        if (car_scene[i] <= 0) break;
        car_root[i] = ui_3d_gltf_root(car_scene[i]);
        g_objects = g_objects + ui_3d_gltf_object_count(car_scene[i]);
        ui_3d_obj_set_scale(car_root[i], CAR_SCALE);
    }
    for (i = 0; i < NF; i = i + 1) {
        fruit_scene[i] = load_first(vp, kFruitPaths, 3, &used_fruit);
        if (fruit_scene[i] <= 0) break;
        fruit_root[i] = ui_3d_gltf_root(fruit_scene[i]);
        g_objects = g_objects + ui_3d_gltf_object_count(fruit_scene[i]);
        ui_3d_obj_set_scale(fruit_root[i], FRUIT_SCALE);
        ui_3d_obj_set_spin(fruit_root[i], 0.0f, 48.0f, 0.0f);
    }
    cut_scene = load_first(vp, kCutPaths, 3, &used_cut);
    if (cut_scene > 0) g_objects = g_objects + ui_3d_gltf_object_count(cut_scene);
    printf("GLTF car=%s fruit=%s cutout=%s\n", used_car, used_fruit, used_cut);

    /* ── 摆位 ─────────────────────────────────────────────────────────────── */
    /* -- Placement -- */
    place_fruits_spots();
    printf("FRUITS");
    for (i = 0; i < NF; i = i + 1) printf(" (%.1f,%.1f)", (double)f_x[i], (double)f_z[i]);
    printf("\n");
    for (i = 0; i < NF; i = i + 1) {
        ui_3d_obj_set_pos(fruit_root[i], f_x[i], terr_h(f_x[i], f_z[i]) + FRUIT_LIFT, f_z[i]);
        ui_3d_obj_set_pos(mini_mark[i], f_x[i], 0.0f, f_z[i]);
    }
    {
        float px2; float pz2;
        px2 = 20.0f; pz2 = -26.0f;
        ui_3d_obj_set_pos(car_root[1], px2, terr_h(px2, pz2) + CAR_LIFT, pz2);
        ui_3d_obj_set_rot(car_root[1], 0.0f, 35.0f, 0.0f);
        px2 = -28.0f; pz2 = 16.0f;
        ui_3d_obj_set_pos(car_root[2], px2, terr_h(px2, pz2) + CAR_LIFT, pz2);
        ui_3d_obj_set_rot(car_root[2], 0.0f, 210.0f, 0.0f);
    }

    reset_game();
    place_car();
    game_view();

    /* ══ 判据段 ═════════════════════════════════════════════════════════════
     * == The criteria ==
     * ⚠ 这一段会**故意**把相机、灯光、开关拨来拨去,逐条量完再放回去 —— 所以每一小段
     * ⚠ This section deliberately moves the camera, lights and switches around, measuring and then
     *   结束前都要 `restore_view()`/复位,否则下一条判据量到的是上一条的残余状态。 */
    /*   restoring after each item; otherwise the next criterion measures leftovers. */
    /* ⚠⚠ **先把动画时钟钉住**(手动挡):果子在自转、宿主每帧都在推进时钟 ⇒ 不钉住的话
     * ⚠⚠ **Pin the animation clock first**: the fruit spins and the host advances the clock every
     *   "同一状态读两次读数一样"这条前提不成立,所有"关掉再打开要逐位还原"的判据全会假红
     *   frame, so "the same state reads the same twice" fails and every bit-identical restore
     *   (第一版实测:伽马关掉还原时差了 284,而画面本身是对的)。
     *   criterion goes falsely red (measured in the first version: 284 off on a correct picture). */
    if (ROVER_CHECK) {
    ui_3d_viewport_set_time(vp, 0.0f);
    ui_3d_viewport_set_time(vpmini, 0.0f);
    check_world();
    check_gamma();
    check_lights();
    ui_3d_viewport_set_pbr(vp, 1);
    check_shadow();
    check_cutout();
    check_mipmap();
    check_pbr_env();
    check_ortho();
    check_rules();
    /* 时钟交回宿主(`-1` 必须是 float 位模式:写成 int 时宿主按 NaN 读、**静默拒绝**)。 */
    /* Hand the clock back to the host; -1 must travel as a float bit pattern or the host rejects it. */
    ui_3d_viewport_set_time(vp, -1.0f);
    ui_3d_viewport_set_time(vpmini, -1.0f);
    }

    /* 判据段改过的东西全部复位,回到"正常的游戏状态"。 */
    /* Put everything the criteria touched back, so the game runs in its normal state. */
    ui_3d_viewport_set_pbr(vp, 1);
    ui_3d_viewport_set_gamma(vp, 3);
    ui_3d_viewport_set_shadow_map(vp, 0);
    ui_3d_viewport_set_shadow(vp, 0);
    ui_3d_viewport_set_ortho(vpmini, 52.0f);
    ui_2d_clear(0);
    ui_2d_present();

    /* ══ 跑起来 ═════════════════════════════════════════════════════════════ */
    /* == Run == */
    frame = 0;
    last_ms = ui_time_get_ms();
    gam = 3;
    {
        float dt;
        float acc;
        while (1) {
            int keys_thr; int keys_turn; int now; char buf[96];
            /* 输入:方向盘 / WASD(自检模式下由自动驾驶写同一对变量)。 */
            /* Input: arrow keys / WASD (in self-check mode the autopilot writes the same pair). */
            keys_thr = 0; keys_turn = 0;
            if (ROVER_PLAY) {
                if (ui_key_is_down(VML_KEY_UP) || ui_key_is_down(87)) keys_thr = 1;
                if (ui_key_is_down(VML_KEY_DOWN) || ui_key_is_down(83)) keys_thr = keys_thr - 1;
                if (ui_key_is_down(VML_KEY_LEFT) || ui_key_is_down(65)) keys_turn = keys_turn - 1;
                if (ui_key_is_down(VML_KEY_RIGHT) || ui_key_is_down(68)) keys_turn = keys_turn + 1;
                in_thr = (float)keys_thr;
                in_turn = (float)keys_turn;
                if (ui_key_is_down(VML_KEY_ESCAPE)) break;
                /* 'G' 键切换输出变换。 */
                /* 'G' cycles the output transform. */
                if (ui_key_is_down(71)) {
                    gam = gam + 1;
                    if (gam > 3) gam = 0;
                    ui_3d_viewport_set_gamma(vp, gam);
                }
                if (state != 0 && ui_key_is_down(VML_KEY_SPACE)) {
                    reset_game();
                    place_car();
                    game_view();
                }
            } else {
                /* 自动驾驶**一直开到这一局结束**(收满或超时),不是开固定帧数就撂挑子 ——
                 * The autopilot drives until the round ends, not for a fixed number of frames:
                 * 原先写到 `AUTO_FRAMES` 就松手,于是车停在半路、剩下几百帧画面一动不动。
                 * the first version let go after AUTO_FRAMES and the car just sat there. */
                if (state == 0) autopilot();
                else { in_thr = 0.0f; in_turn = 0.0f; }
            }

            now = ui_time_get_ms();
            dt = (float)(now - last_ms) / 1000.0f;
            last_ms = now;
            if (dt > 0.5f) dt = 0.5f;
            if (dt < 0.0f) dt = 0.0f;
            acc = dt;
            step_game(acc);
            game_view();

            /* 小地图:玩家标记跟着走,果子标记随收集熄灭。 */
            /* Minimap: the player marker follows, the fruit markers go out as they are taken. */
            ui_3d_obj_set_pos(mini_player, p_x, 0.0f, p_z);

            ui_2d_clear(0);
            ui_2d_text(12, 10, "3D 漫游寻果 Rover 3D", C_HUD, 15, VML_ANCHOR_LEFT);
            sprintf(buf, "score %d / %d   time %.1f s", score, NF, (double)time_left);
            ui_2d_text(12, 32, buf, C_HUD_DIM, 12, VML_ANCHOR_LEFT);
            sprintf(buf, "objects %d   lights %d", g_objects, NLIGHT);
            ui_2d_text(12, 50, buf, C_HUD_DIM, 11, VML_ANCHOR_LEFT);
            if (state == 1) ui_2d_text(12, 74, "全部收完!WIN", C_GOOD, 15, VML_ANCHOR_LEFT);
            if (state == 2) ui_2d_text(12, 74, "时间到 TIME UP", C_WARN, 15, VML_ANCHOR_LEFT);
            if (state != 0 && ROVER_PLAY) ui_2d_text(12, 96, "按空格重来 SPACE", C_HUD, 12, VML_ANCHOR_LEFT);

            ui_2d_present();
            /* ⚠ **帧节流**:不配这一句,主循环会跑得比屏幕快几个数量级、画面一闪而过。
             * ⚠ **Frame throttle**: without it the main loop outruns the display and the picture flickers by.
             *   上屏节奏由宿主管(`PaceFrame`),这里只是"等输入最多等多久"。 */
            /*   The host owns the pacing; this line only means "wait for input at most this long". */
            ui_msg_fetch_wait(40);

            frame = frame + 1;
            if (ROVER_PLAY == 0 && state != 0) break;
            if (ROVER_PLAY == 0 && frame >= AUTO_FRAMES * 7) break;
        }
    }

    printf("ROVER-DONE frames=%d score=%d taken=%d state=%d\n", frame, score, score, state);
    if (ROVER_CHECK) printf("ROVER3D_GAME %s (fails=%d)\n", g_fails == 0 ? "PASS" : "FAIL", g_fails);
    else printf("ROVER3D_GAME SKIP (checks off; not a pass)\n");
    return 0;
}