/* landscape.c —— **综合写实室外风景**：把 2026-10-08 那批 3D 接口**合起来**用一个场景演一遍。
 * landscape.c — **a combined outdoor landscape**: every 3D interface added on 2026-10-08, used together.
 *
 * 起伏的地形（高度场 + 逐顶点法线）、程序化天空（等距圆柱天空盒）、暖阳 + 冷天光两盏灯、地面材质高光、岩石法线贴图、远近空气感（线性雾）、树影落到草地、池塘水面。
 * Rolling terrain (height field, per-vertex normals), a procedural sky (equirectangular skybox), a warm sun
 * plus a cool sky light, specular ground material, a rock normal map, aerial perspective (linear fog),
 * tree shadows on the lawn, and a pond surface.
 *
 * ## 为什么要"合起来"演一遍
 * ## Why "together" matters
 *
 * 每一项单独对，不等于合起来对：接口之间会互相踩（雾与天空盒的远近、影子的接收面、多光源的连续性、法线贴图对 uv 的依赖）。
 * Each piece working alone does not mean they work together: fog fights the skybox over distance, shadows
 * need a receiving plane, multiple lights must stay contiguous, and normal maps depend on uv. A focused
 * example only proves its own link in the chain.
 *
 * ## ⚠ 本场景刻意遵守的几条硬约束（写错了不报错、只是画面不对）
 * ## ⚠ Hard constraints this scene deliberately obeys (breaking them reports nothing, the picture is just wrong)
 *
 * 草地**严格 y = 0**：平面阴影的接收面固定在那儿，起伏的地面会让影子浮起来或被埋掉。
 * The lawn is **exactly y = 0**: that is the fixed shadow-receiving plane, and an undulating ground makes shadows float or sink.
 *
 * 主光的 **y 分量必须 > 0.05**，否则一个影子都不生成（水平光那条路是静默的）。
 * The main light's **y must exceed 0.05**, otherwise no shadow is generated at all (the horizontal-light path is silent).
 *
 * 两盏灯必须**从 0 起连续**：亮灯数是"从第 0 盏连续数到第一盏被关掉的"，中间留洞会让后面那盏也画不出来。
 * Lights must be **contiguous from index 0**: the lit count runs from light 0 until the first one switched off, so a hole silently kills everything after it.
 *
 * 所有东西都在相机 **180 以内**：天空盒半径固定 180（本仓没有"无限远"），更远的东西会被它盖住。
 * Everything stays within **180** of the camera: the skybox has a fixed radius of 180 (there is no "infinity" here) and occludes anything farther.
 *
 * 地形网格的 uv **必须变化**：法线贴图的 TBN 靠屏幕空间导数，uv 退化时它整个跳过。
 * The terrain's uv **must vary**: the normal map's TBN comes from screen-space derivatives and skips itself when uv degenerates.
 *
 * ## 跑法
 * ## How to run
 *   dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll \
 *          third_party/vml/Examples/c/3d/landscape.c --screen 393x757
 *
 * 机器判读：逐行 `PASS …` / `FAIL …`，末行 `LANDSCAPE PASS|FAIL (fails=N)`。
 * Machine-readable: one `PASS ...` / `FAIL ...` line each, ending with `LANDSCAPE PASS|FAIL (fails=N)`.
 */

#include <vml_ui.h>
#include <vml_3d.h>
#include <math.h>
#include <stdio.h>
#include <file.h>

/* ══ 记分 ══════════════════════════════════════════════════════════════════ */
/* == Scoring == */

int g_fails;

/* 判据除了打 stdout，还写一份**结果文件**。
 * Besides stdout, every criterion is also written to a **result file**.
 *
 * ⚠ 手机端跑开窗程序时命令行页会被绘图页顶掉，驱动"看到窗口就算跑完" ⇒ 只抓屏会漏掉判据正文。
 * ⚠ On a phone a window-opening program hides the command-line page and the harness counts "window appeared" as done, so scraping the screen misses the criteria.
 *
 * 结果文件落在 cwd（手机上是工作区根），`adb pull` 直接取得到。
 * The file lands in cwd (the workspace root on a phone) and is directly pullable with `adb pull`. */

char RLINE[192];

/* ⚠ 句柄 **0 是合法值**（实测 `fopen` 成功返回 0）⇒ 判"开没开成"必须用**负值**。
 * ⚠ Handle **0 is valid** (measured: a successful `fopen` returns 0), so "did it open" must test for a **negative** value. */

int g_rpt = -2;   /* -2 = 还没试过，>= 0 = 已打开，< 0 = 开不了 */

void rpt_line(char* s) {
    int n;
    if (g_rpt == 0 - 2) g_rpt = fopen("_3d_landscape_result.txt", 1);
    if (g_rpt < 0) return;
    n = 0;
    while (s[n] != 0) n = n + 1;
    fwrite(g_rpt, s, n);
}

void ck(char* tag, int cond, int a, int b) {
    if (cond) { printf("PASS %s %d %d\n", tag, a, b); }
    else { g_fails = g_fails + 1; printf("FAIL %s %d %d\n", tag, a, b); }
    sprintf(RLINE, "%s %s %d %d\n", cond ? "PASS" : "FAIL", tag, a, b);
    rpt_line(RLINE);
}

int rch(int c) { return (c >> 16) & 0xFF; }
int gch(int c) { return (c >> 8) & 0xFF; }
int bch(int c) { return c & 0xFF; }
int lum(int c) { return rch(c) + gch(c) + bch(c); }
int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }
int argba(int a, int r, int g, int b) { return a * 16777216 + r * 65536 + g * 256 + b; }

/* ══ 网格装配（pos3 + nrm3 + uv2 = 8 个 float32 = 32 字节，索引 int32 紧随其后）════
 * == Mesh assembly (pos3 + nrm3 + uv2 = 8 float32 = 32 bytes, then int32 indices) == */

/* ⚠ 数组要装得下「顶点数 × 8 + 索引数」个 float；算小了网格建得出来但一个像素都不画。
 * ⚠ The array must hold (vertexCount * 8 + indexCount) floats; too small and the mesh exists but draws nothing. */

/* 容量按**最大的那次生成**算：`gen_terrain(48,…)` = 49×49 顶点 × 8 + 48×48×6 索引 = 33032。
 * The capacity is sized for the **largest generation**: gen_terrain(48,...) = 49x49 vertices x 8 + 48x48x6 indices = 33032. */

/* ⚠ 从前这里写 24000（小于 33032）⇒ 索引写到数组外面 36 KB，把后面的全局变量整片写烂。
 * ⚠ This used to say 24000 (below 33032), so indices wrote 36 KB past the array and smashed the globals after it. */

/* 症状不是崩，而是**判据与画面一起变得莫名其妙**（法线贴图不生效、雾取不到山脊…）——
 * The symptom is not a crash but criteria and picture both going inexplicable: no normal-map effect, fog missing the ridge. */

/* 所以下面**必须**配一条 `mesh buffer fits` 守卫：把细分数调大的人会立刻看到红，而不是被静默写烂。
 * Hence the `mesh buffer fits` guard below: raising the subdivision turns it red instead of silently corrupting memory. */

#define MB_CAP 42000
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 = 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;
}
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]; }

/* ══ 地形高度场 ═════════════════════════════════════════════════════════════ */
/* == Terrain height field == */

/* 中心草地**严格为 0**（影子接收面），远处抬起成山，右边挖一个池塘。
 * The centre meadow is **exactly 0** (the shadow plane), hills rise at the rim, and a pond is dug to the right. */

float terr_h(float x, float z) {
    float r = sqrt(x * x + z * z);
    float h = 0.0f;
    if (r > 20.0f) {
        float t = (r - 20.0f) / 16.0f;
        if (t > 1.0f) t = 1.0f;
        h = h + 11.0f * t * t;
    }
    {
        float dx = x - 11.0f;
        float dz = z + 7.0f;
        float pr = sqrt(dx * dx + dz * dz);
        if (pr < 5.0f) {
            float t = 1.0f - pr / 5.0f;
            h = h - 1.8f * t * t;
        }
    }
    return h;
}

/* 地形的法线：对高度场做中心差分（不解析求导，写起来短且与网格一致）。
 * The terrain normal: central differences over the height field (shorter than analytic derivatives and consistent with the mesh). */

void terr_normal(float x, float z, float d, float* out) {
    float hl = terr_h(x - d, z);
    float hr = terr_h(x + d, z);
    float hd = terr_h(x, z - d);
    float hu = terr_h(x, z + d);
    float nx = 0 - (hr - hl) / (2.0f * d);
    float nz = 0 - (hu - hd) / (2.0f * d);
    float ny = 1.0f;
    float len = sqrt(nx * nx + ny * ny + nz * nz);
    out[0] = nx / len; out[1] = ny / len; out[2] = nz / len;
}

/* 地形网格：`segs` 段、半边长 `half`、uv 重复 `rep` 次（uv 必须变化，见文件头）。
 * The terrain mesh: `segs` subdivisions, half-extent `half`, uv repeated `rep` times (uv must vary, see the header). */

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

/* 单位球：法线取归一化位置。
 * A unit sphere: the normal is the normalised position. */

void gen_sphere(int rings, int segs) {
    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_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 = 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;
}

/* 长方体：**每个面给常量法线**（拿归一化位置当法线在角上会朝 45° 歪掉，树干会看起来是圆的）。
 * A box with a **constant normal per face** (using the normalised position would tilt the corners 45 degrees and round off the trunk). */

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;
    /* +X */ 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;
    /* -X */ 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;
    /* +Y */ 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;
    /* -Y */ 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;
    /* +Z */ 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;
    /* -Z */ 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 b = v * 4;
        mb_index(k, b);     k = k + 1;
        mb_index(k, b + 1); k = k + 1;
        mb_index(k, b + 2); k = k + 1;
        mb_index(k, b);     k = k + 1;
        mb_index(k, b + 2); k = k + 1;
        mb_index(k, b + 3); k = k + 1;
    }
    g_in = k;
}

/* ══ 程序化图像 ═════════════════════════════════════════════════════════════ */
/* == Procedural images == */

/* 采样：在某个屏幕点周围取 5×5（逐点读回是线程往返，必须抽稀）。
 * Sampling: a 5x5 grid around a screen point (each read is a thread round-trip, so keep it sparse). */

int min_lum(int vp, int cx, int cy, int rad) {
    int i; int j; int m; int c;
    m = 99999;
    for (j = -2; j <= 2; j = j + 1)
        for (i = -2; i <= 2; i = i + 1) {
            c = ui_3d_viewport_get_pixel(vp, cx + i * rad / 2, cy + j * rad / 2);
            if (lum(c) < m) m = lum(c);
        }
    return m;
}

/* 均值：**比 min 更能反映"这片整体变暗了"** —— min 会被区域里最暗的那一小块钉住。
 * Mean: **better than min at showing "the whole patch got darker"** - min is pinned by the darkest spot. */

/* 实测教训：草地法线贴图那一条用 min 时读回**逐位相同**（光照明明变了，min 不动）⇒ 判据不响。
 * Measured lesson: with min the grass-normal criterion read **bit-identical** although the lighting did change. */

int avg_lum(int vp, int cx, int cy, int rad) {
    int i; int j; int s; int n;
    s = 0; n = 0;
    for (j = -2; j <= 2; j = j + 1)
        for (i = -2; i <= 2; i = i + 1) {
            s = s + lum(ui_3d_viewport_get_pixel(vp, cx + i * rad / 2, cy + j * rad / 2));
            n = n + 1;
        }
    return s / n;
}

/* 天空盒：等距圆柱图。上半天是天空渐变 + 太阳 + 云，下半天是地面雾霭。
 * The skybox: an equirectangular image. The upper half is a sky gradient with a sun and clouds, the lower half ground haze. */

#define SKY_W 128
#define SKY_H 64

int sky_pixel(int x, int y) {
    int v;
    if (y < SKY_H / 2) {
        float t = (float)y / (SKY_H / 2);
        int r = (int)(150.0f - 96.0f * t);
        int g = (int)(190.0f - 88.0f * t);
        int b = (int)(238.0f - 40.0f * t);
        /* 云：几团柔和的亮斑。 */
        /* Clouds: a few soft bright blobs. */
        float cx1 = (x - 30) * (x - 30) + (y - 12) * (y - 12) * 4.0f;
        float cx2 = (x - 78) * (x - 78) + (y - 20) * (y - 20) * 4.0f;
        float cx3 = (x - 104) * (x - 104) + (y - 9) * (y - 9) * 4.0f;
        if (cx1 < 150.0f) { r = r + (int)(70.0f - cx1 * 0.4f); g = g + (int)(70.0f - cx1 * 0.4f); b = b + (int)(60.0f - cx1 * 0.4f); }
        if (cx2 < 190.0f) { r = r + (int)(70.0f - cx2 * 0.35f); g = g + (int)(70.0f - cx2 * 0.35f); b = b + (int)(60.0f - cx2 * 0.35f); }
        if (cx3 < 120.0f) { r = r + (int)(70.0f - cx3 * 0.5f); g = g + (int)(70.0f - cx3 * 0.5f); b = b + (int)(60.0f - cx3 * 0.5f); }
        /* 太阳：一个亮盘 + 一圈晕。 */
        /* The sun: a bright disc with a halo. */
        float sx = (x - 96) * (x - 96) + (y - 8) * (y - 8) * 4.0f;
        if (sx < 40.0f) { r = 255; g = 250; b = 225; }
        else if (sx < 300.0f) { r = r + (int)(90.0f - sx * 0.28f); g = g + (int)(85.0f - sx * 0.26f); b = b + (int)(50.0f - sx * 0.15f); }
        v = r; if (v < 0) v = 0; if (v > 255) v = 255; r = v;
        v = g; if (v < 0) v = 0; if (v > 255) v = 255; g = v;
        v = b; if (v < 0) v = 0; if (v > 255) v = 255; b = v;
        return argb(r, g, b);
    }
    return argb(120, 132, 120);
}

/* 草地纹理：起伏的绿，带一点随机感的斑块（用几个正弦叠，确定性、不用随机数）。
 * Grass texture: rolling greens with patchiness (a few summed sines, deterministic, no RNG). */

float grass_n(int x, int y) {
    float fx = x * 0.5f;
    float fy = y * 0.5f;
    return 0.5f * sin(fx) * cos(fy)
         + 0.3f * sin(fx * 2.7f + 0.7f) * cos(fy * 3.1f + 1.3f)
         + 0.2f * sin(fx * 6.1f + 2.1f) * cos(fy * 5.3f + 0.3f);
}

void draw_grass(int px0, int py0, int n) {
    int x; int y;
    for (y = 0; y < n; y = y + 1)
        for (x = 0; x < n; x = x + 1) {
            float k = grass_n(x, y);
            int r = (int)(96.0f + 46.0f * k);
            int g = (int)(132.0f + 56.0f * k);
            int b = (int)(70.0f + 34.0f * k);
            if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
            if (r > 255) r = 255; if (g > 255) g = 255; if (b > 255) b = 255;
            ui_2d_rect(px0 + x, py0 + y, 1, 1, argb(r, g, b), 1, 0, 0);
        }
}

/* 草地的法线贴图：从高度场推（Sobel）——现成例程里没有这一块。
 * The grass normal map, derived from the height field (Sobel): nothing in the existing examples does this. */

void draw_grass_normal(int px0, int py0, int n) {
    int x; int y;
    for (y = 0; y < n; y = y + 1)
        for (x = 0; x < n; x = x + 1) {
            float hl = grass_n((x + n - 1) % n, y);
            float hr = grass_n((x + 1) % n, y);
            float hd = grass_n(x, (y + n - 1) % n);
            float hu = grass_n(x, (y + 1) % n);
            float nx = 0 - (hr - hl) * 1.1f;
            float ny = 0 - (hu - hd) * 1.1f;
            float nz = 1.0f;
            float len = sqrt(nx * nx + ny * ny + nz * nz);
            int r = (int)(127.5f + 127.0f * nx / len);
            int g = (int)(127.5f + 127.0f * ny / len);
            int b = (int)(127.5f + 127.0f * nz / len);
            if (r < 0) r = 0; if (r > 255) r = 255;
            if (g < 0) g = 0; if (g > 255) g = 255;
            if (b < 0) b = 0; if (b > 255) b = 255;
            ui_2d_rect(px0 + x, py0 + y, 1, 1, argb(r, g, b), 1, 0, 0);
        }
}

int main(void) {
    int w; int h; int vp;
    int m_terr; int m_sph; int m_box; int m_pond;
    int o_terr; int o_rock; int o_rock2; int o_tree1; int o_tree2; int o_crown1; int o_crown2;
    int o_ball; int o_pond;
    int img_sky; int img_grass; int img_gnorm; int img_neutral;
    int r; int sc; int px; int py;
    int a0; int a1; int t0; int t1;
    int sky_x; int sky_y;
    int lang;

    g_fails = 0;
    lang = ui_scr_get_language();

    ui_win_open_ex(lang == 0 ? "3D 写实风景" : "3D Landscape",
                   0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    w = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    h = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    printf("SCR=%dx%d\n", w, h);
    if (w < 64 || h < 64) { printf("LANDSCAPE FAIL (fails=99)\n"); return 99; }

    vp = ui_3d_viewport_create(0, 0, w, h, 0);
    ck("viewport -> handle", vp > 0, vp, 0);

    /* 相机站在草地上方一点、略微俯视；所有几何都在 180 以内（天空盒半径）。
     * The camera stands just above the lawn looking slightly down; all geometry is within 180 (the skybox radius). */

    ui_3d_viewport_set_camera(vp, 0.0f, 7.5f, 30.0f, 0.0f, 1.0f, 0.0f);
    ui_3d_viewport_set_fov(vp, 55.0f, 0.5f, 8000.0f);
    ui_3d_viewport_set_bg(vp, argb(28, 40, 60));

    /* ── ① 两盏灯：暖阳（主光，y 明显为正 ⇒ 影子才能生成）+ 冷天光 ───────────── */
    /* ① Two lights: a warm sun (main light, y clearly positive so shadows are generated) and a cool sky light */
    r = ui_3d_viewport_set_light(vp, 0.42f, 0.78f, 0.46f, argb(255, 244, 214), 0.16f);
    ck("set_light(sun) rc", r == 0, r, 0);
    r = ui_3d_viewport_set_light_at(vp, 1, 0 - 0.55f, 0.35f, 0 - 0.62f, argb(120, 160, 225), 0.14f);
    ck("set_light_at(1) rc", r == 0, r, 0);

    r = ui_3d_viewport_set_fog(vp, argb(176, 200, 224), 22.0f, 95.0f);
    ck("set_fog rc", r == 0, r, 0);

    /* ── ② 天空盒（程序化等距圆柱图） ─────────────────────────────────────── */
    /* ② The skybox (a procedural equirectangular image) */
    {
        int x; int y;
        for (y = 0; y < SKY_H; y = y + 1)
            for (x = 0; x < SKY_W; x = x + 1)
                ui_2d_rect(4 + x, 4 + y, 1, 1, sky_pixel(x, y), 1, 0, 0);
        ui_2d_present();
        img_sky = ui_2d_get_image(4, 4, SKY_W, SKY_H);
        ck("skybox image -> handle", img_sky > 0, img_sky, 0);

        /* 先量一次"没有天空盒"的天顶像素，装上之后再量 —— 必须变；清掉之后必须逐位还原。
         * Read the zenith pixel without the skybox, then with it (must change), then clear it (must restore bit-identically). */
        ui_2d_present();
        sky_x = w / 2; sky_y = 6;
        a0 = ui_3d_viewport_get_pixel(vp, sky_x, sky_y);
        r = ui_3d_viewport_set_skybox(vp, img_sky);
        ck("set_skybox rc", r == 0, r, 0);
        ui_2d_present();
        a1 = ui_3d_viewport_get_pixel(vp, sky_x, sky_y);
        printf("SKY zenith before=%d after=%d\n", a0, a1);
        ck("skybox changes the zenith", a1 != a0, a1, a0);
    }

    /* ── ③ 地形：高度场 + 草地贴图 + 法线贴图 + 高光材质 ───────────────────── */
    /* ③ Terrain: height field, grass texture, normal map, specular material */
    gen_terrain(48, 40.0f, 14.0f);

    /* 先量缓冲够不够 —— 算小了**不报错**，只是把内存写烂，判据会以"莫名其妙"的形式变红。
     * Check the buffer first: too small reports nothing, it just smashes memory and the criteria fail inexplicably. */
    ck("mesh buffer fits (terrain)", g_vn * 8 + g_in <= MB_CAP, g_vn * 8 + g_in, MB_CAP);

    m_terr = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    ck("terrain mesh -> handle", m_terr > 0, m_terr, 0);
    o_terr = ui_3d_obj_create(vp, m_terr);
    ck("terrain object -> handle", o_terr > 0, o_terr, 0);

    /* 物体色设**白**：贴图是乘性 tint，不设白就读成"物体色 × 纹素"。
     * The object colour is **white**: the texture is a multiplicative tint, otherwise you read colour x texel. */
    ui_3d_obj_set_color(o_terr, argb(255, 255, 255));

    /* 草地贴图与法线图（两张图分别抓，互不影响）。
     * The grass albedo and normal maps, grabbed separately from the canvas. */
    draw_grass(4, 80, 64);
    ui_2d_present();
    img_grass = ui_2d_get_image(4, 80, 64, 64);
    ck("grass image -> handle", img_grass > 0, img_grass, 0);

    draw_grass_normal(4, 152, 64);
    ui_2d_present();
    img_gnorm = ui_2d_get_image(4, 152, 64, 64);
    ck("grass normal image -> handle", img_gnorm > 0, img_gnorm, 0);

    ui_2d_rect(4, 224, 64, 64, argb(128, 128, 255), 1, 0, 0);
    ui_2d_present();
    img_neutral = ui_2d_get_image(4, 224, 64, 64);
    ck("neutral normal image -> handle", img_neutral > 0, img_neutral, 0);

    /* ⚠⚠ 抓完图**必须把 2D 层清成透明** —— 3D 视口是**透过 2D 层**看的。
     * ⚠⚠ Once the grabs are done the 2D layer **must** be cleared to transparent: the 3D viewport shows **through** it. */

    /* 不清的症状是「窗口开了、场景全黑，只有你画在 2D 上的那几张图」—— 实测踩了一次。
     * Uncleared, the symptom is "the window opens but the scene is black, with only the images you drew on the 2D canvas" - measured, once. */

    ui_2d_clear(0);
    ui_2d_present();

    r = ui_3d_obj_set_tex(o_terr, img_grass);
    ck("terrain set_tex rc", r == 0, r, 0);

    /* 湿地面：中等指数的高光（`specular = 0` 是无高光的默认值）。
     * Damp ground: a mid-exponent highlight (`specular = 0` is the default of no highlight at all). */
    ui_3d_obj_set_material(o_terr, argb(180, 200, 210), 40.0f);

    /* 地形法线贴图：中性图应几乎不变、草地法线图必须改变光照（相对判据，与光照量级无关）。
     * The terrain normal map: neutral should barely change; the grass map must change the lighting (a relative criterion, independent of light level). */
    ui_2d_present();
    sc = ui_3d_obj_get_screen(o_terr);
    px = sc & 0xFFFF; py = (sc >> 16) & 0xFFFF;

    /* 地形中心在屏幕上的位置未必落在地面上（可能被树挡住），所以采样一个"草地上"的点。
     * The terrain's reported centre may not land on open lawn (a tree may cover it), so probe a point on the grass. */
    /* ⚠ 统计量必须用**均值**，不能用最小值：min 被"这片里最暗的那一小块"钉住。
     * ⚠ The statistic must be the **mean**, not the min: min is pinned by the darkest spot in the patch. */

    /* 实测：从前用 min 时这一条读回**逐位相同**（72 == 72）—— 光照确实变了、判据却不响。
     * Measured: with min this criterion read **bit-identical** (72 == 72) although the lighting really did change. */

    t0 = avg_lum(vp, px, h * 3 / 4, w / 6);
    printf("NORMAL base min=%d avg=%d\n", min_lum(vp, px, h * 3 / 4, w / 6), t0);
    ui_3d_obj_set_normal_map(o_terr, img_neutral);
    ui_2d_present();
    t1 = avg_lum(vp, px, h * 3 / 4, w / 6);
    printf("NORMAL neutral min=%d avg=%d\n", min_lum(vp, px, h * 3 / 4, w / 6), t1);
    ck("neutral normal map is the identity",
       (t0 > t1 ? t0 - t1 : t1 - t0) <= 2, t0, t1);

    ui_3d_obj_set_normal_map(o_terr, img_gnorm);
    ui_2d_present();
    {
        int t2;
        t2 = avg_lum(vp, px, h * 3 / 4, w / 6);
        printf("NORMAL grass min=%d avg=%d\n", min_lum(vp, px, h * 3 / 4, w / 6), t2);
        ck("grass normal map changes the lighting",
           (t0 > t2 ? t0 - t2 : t2 - t0) > 4, t0, t2);
    }

    /* ── ④ 树与石头（不透明，会投下影子） ─────────────────────────────────── */
    /* ④ Trees and rocks (opaque, and they cast shadows) */
    gen_box(0.55f, 2.6f, 0.55f);
    m_box = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_sphere(14, 20);
    m_sph = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    ck("tree meshes -> handles", m_box > 0 && m_sph > 0, m_box, m_sph);

    o_tree1 = ui_3d_obj_create(vp, m_box);
    ui_3d_obj_set_pos(o_tree1, 0 - 8.5f, 2.6f, 0 - 3.0f);
    ui_3d_obj_set_color(o_tree1, argb(120, 84, 58));
    o_crown1 = ui_3d_obj_create(vp, m_sph);
    ui_3d_obj_set_pos(o_crown1, 0 - 8.5f, 6.4f, 0 - 3.0f);
    ui_3d_obj_set_scale(o_crown1, 2.6f);
    ui_3d_obj_set_color(o_crown1, argb(64, 122, 58));
    ui_3d_obj_set_material(o_crown1, argb(120, 160, 110), 20.0f);

    o_tree2 = ui_3d_obj_create(vp, m_box);
    ui_3d_obj_set_pos(o_tree2, 0 - 14.0f, 2.6f, 4.5f);
    ui_3d_obj_set_color(o_tree2, argb(112, 78, 54));
    o_crown2 = ui_3d_obj_create(vp, m_sph);
    ui_3d_obj_set_pos(o_crown2, 0 - 14.0f, 6.2f, 4.5f);
    ui_3d_obj_set_scale(o_crown2, 2.3f);
    ui_3d_obj_set_color(o_crown2, argb(72, 132, 64));
    ui_3d_obj_set_material(o_crown2, argb(120, 160, 110), 20.0f);

    /* 一块石头：低指数大高光 ⇒ 看着潮。
     * A boulder: a low-exponent, broad highlight, which reads as damp. */
    o_rock = ui_3d_obj_create(vp, m_sph);
    ui_3d_obj_set_pos(o_rock, 3.5f, 0.9f, 0 - 6.0f);
    ui_3d_obj_set_scale(o_rock, 1.5f);
    ui_3d_obj_set_color(o_rock, argb(150, 146, 138));
    ui_3d_obj_set_material(o_rock, argb(235, 235, 240), 90.0f);

    /* 一块**悬空**的石头 —— 专门用来考影子（正下方那块草地是干净的）。
     * A **hovering** boulder, used to test the shadow (the lawn right below it is otherwise clean). */
    o_rock2 = ui_3d_obj_create(vp, m_sph);
    ui_3d_obj_set_pos(o_rock2, 6.5f, 5.5f, 0 - 2.0f);
    ui_3d_obj_set_scale(o_rock2, 0.9f);
    ui_3d_obj_set_color(o_rock2, argb(170, 160, 150));
    ui_3d_obj_set_material(o_rock2, argb(255, 250, 240), 120.0f);

    /* ── ⑤ 池塘水面（透明）───────────────────────────────────────────────── */
    /* ⑤ The pond surface (transparent) */
    gen_terrain(6, 4.5f, 1.0f);
    m_pond = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    o_pond = ui_3d_obj_create(vp, m_pond);
    ui_3d_obj_set_pos(o_pond, 11.0f, 0 - 0.75f, 0 - 7.0f);
    ui_3d_obj_set_color(o_pond, argba(150, 60, 120, 150));
    ck("pond object -> handle", o_pond > 0, o_pond, 0);

    /* ── ⑥ 影子：两块石头与树干都在草地上投影 ─────────────────────────────── */
    /* ⑥ Shadows: the boulders and trunks cast onto the lawn */
    {
        int s0; int s1;
        sc = ui_3d_obj_get_screen(o_rock2);
        px = sc & 0xFFFF; py = (sc >> 16) & 0xFFFF;
        printf("BALLSCREEN=%d,%d\n", px, py);
        ui_2d_present();
        s0 = avg_lum(vp, px, py + h / 6, w / 5);
        r = ui_3d_viewport_set_shadow(vp, 1);
        ck("set_shadow rc", r == 0, r, 0);
        ui_2d_present();
        s1 = avg_lum(vp, px, py + h / 6, w / 5);
        printf("SHADOW off min=%d avg=%d / on min=%d avg=%d\n",
               min_lum(vp, px, py + h / 6, w / 5), s0,
               min_lum(vp, px, py + h / 6, w / 5), s1);
        ck("shadow darkens the lawn below the boulder", s1 < s0, s1, s0);
        r = ui_3d_viewport_set_shadow(vp, 0);
        ck("clear shadow rc", r == 0, r, 0);
        ui_2d_present();
        ck("shadow off -> identical to baseline",
           avg_lum(vp, px, py + h / 6, w / 5) == s0, avg_lum(vp, px, py + h / 6, w / 5), s0);
        ui_3d_viewport_set_shadow(vp, 1);
    }

    /* ── ⑦ 雾：远处山脊的像素必须向雾色移动 ───────────────────────────────── */
    /* ⑦ Fog: a distant ridge pixel must move towards the fog colour */
    {
        int f0; int f1; int f2;
        ui_2d_present();
        f0 = ui_3d_viewport_get_pixel(vp, w / 2, h / 3);
        r = ui_3d_viewport_set_fog(vp, 0, 0.0f, 0.0f);
        ck("fog off rc", r == 0, r, 0);
        ui_2d_present();
        f1 = ui_3d_viewport_get_pixel(vp, w / 2, h / 3);
        printf("FOG on=%d off=%d\n", f0, f1);
        ck("fog changes the distant ridge", f0 != f1, f0, f1);
        ui_3d_viewport_set_fog(vp, argb(176, 200, 224), 22.0f, 95.0f);
        ui_2d_present();
        f2 = ui_3d_viewport_get_pixel(vp, w / 2, h / 3);
        ck("fog back -> identical to before", f2 == f0, f2, f0);
    }

    /* ── ⑧ 天空盒反证：清掉必须逐位回到"没有天空盒"的读数 ─────────────────── */
    /* ⑧ Skybox reverse: clearing it must return bit-identically to the reading without a skybox */
    {
        int k0; int k1;
        r = ui_3d_viewport_set_skybox(vp, 0);
        ck("clear skybox rc", r == 0, r, 0);
        ui_2d_present();
        k1 = ui_3d_viewport_get_pixel(vp, sky_x, sky_y);
        k0 = a0;
        ck("skybox off -> identical to baseline", k1 == k0, k1, k0);
        ui_3d_viewport_set_skybox(vp, img_sky);
    }

    /* ── ⑨ 透明：池塘水面下能看见塘底（关掉混合 ⇒ 变成不透明色） ─────────────── */
    /* ⑨ Transparency: the pond bed shows through the surface (switch blending off and it becomes opaque) */
    {
        int p0; int p1;
        sc = ui_3d_obj_get_screen(o_pond);
        px = sc & 0xFFFF; py = (sc >> 16) & 0xFFFF;
        ui_2d_present();
        p0 = ui_3d_viewport_get_pixel(vp, px, py);
        r = ui_3d_obj_set_blend(o_pond, 1);
        ck("pond set_blend(1) rc", r == 0, r, 0);
        ui_2d_present();
        p1 = ui_3d_viewport_get_pixel(vp, px, py);
        printf("POND opaque=%d blended=%d\n", p0, p1);
        ck("blended pond differs from opaque", p1 != p0, p1, p0);
        r = ui_3d_obj_set_blend(o_pond, 0);
        ck("pond set_blend(0) rc", r == 0, r, 0);
        ui_2d_present();
        ck("blend off -> identical to opaque", ui_3d_viewport_get_pixel(vp, px, py) == p0,
           ui_3d_viewport_get_pixel(vp, px, py), p0);
        ui_3d_obj_set_blend(o_pond, 1);
    }

    /* ── ⑩ 多光源：再加一盏侧后方补光 ⇒ 受光那一侧**变亮**（相对判据） ───────── */
    /* ⑩ Multiple lights: adding a rim light must **brighten** the side it reaches (a relative criterion) */
    {
        int b0; int b1;
        sc = ui_3d_obj_get_screen(o_rock);
        px = sc & 0xFFFF; py = (sc >> 16) & 0xFFFF;
        ui_2d_present();
        b0 = lum(ui_3d_viewport_get_pixel(vp, px, py));
        r = ui_3d_viewport_set_light_at(vp, 2, 0.72f, 0.30f, 0.62f, argb(255, 226, 196), 0.10f);
        ck("set_light_at(2) rc", r == 0, r, 0);
        ui_2d_present();
        b1 = lum(ui_3d_viewport_get_pixel(vp, px, py));
        printf("RIMLIGHT before=%d after=%d\n", b0, b1);
        ck("rim light brightens the boulder", b1 > b0, b1, b0);
        ui_3d_viewport_set_light_at(vp, 2, 0.72f, 0.30f, 0.62f, 0, 0.0f);
    }

    /* ── ⑪ 材质高光：给石头强高光 ⇒ 高光点变亮；去掉 ⇒ 逐位还原 ─────────────── */
    /* ⑪ Material highlight: a strong specular brightens the highlight; removing it restores bit-identically */
    {
        int h0; int h1; int h2;
        ui_2d_present();
        h0 = lum(ui_3d_viewport_get_pixel(vp, px, py));
        ui_3d_obj_set_material(o_rock, argb(255, 255, 255), 220.0f);
        ui_2d_present();
        h1 = lum(ui_3d_viewport_get_pixel(vp, px, py));
        printf("SPECULAR matte=%d shiny=%d\n", h0, h1);
        ck("specular highlights the boulder", h1 > h0, h1, h0);
        ui_3d_obj_set_material(o_rock, argb(235, 235, 240), 90.0f);
        ui_2d_present();
        h2 = lum(ui_3d_viewport_get_pixel(vp, px, py));
        ck("restoring the material -> identical", h2 == h0, h2, h0);
    }

    /* ── ⑫ 错误路径 ──────────────────────────────────────────────────────── */
    /* ⑫ Error paths */
    ck("bad viewport rejected (shadow)", ui_3d_viewport_set_shadow(9999, 1) < 0, 0, 0);
    ck("bad light index rejected", ui_3d_viewport_set_light_at(vp, 9, 0.0f, 1.0f, 0.0f, argb(255, 255, 255), 0.0f) < 0, 0, 0);
    ck("bad object rejected (normal map)", ui_3d_obj_set_normal_map(9999, img_gnorm) < 0, 0, 0);

    /* 收尾：恢复"该有的样子"，供 --frames 出图。
     * Final: restore the intended look so the exported frames show the scene. */
    ui_3d_obj_set_normal_map(o_terr, img_gnorm);
    ui_3d_obj_set_blend(o_pond, 1);
    ui_3d_viewport_set_shadow(vp, 1);
    ui_3d_viewport_set_fog(vp, argb(176, 200, 224), 22.0f, 95.0f);
    ui_2d_present();

    /* 末帧**停留一会儿**：给人看清画面、也给截图留出时间（判据这时已经全部跑完了）。
     * Hold the final frame for a while: so a human can see it and a screenshot can be taken (the criteria have already finished by now). */

    for (r = 0; r < 300; r = r + 1) ui_msg_fetch_wait(40);

    sprintf(RLINE, "LANDSCAPE %s (fails=%d)\n", g_fails == 0 ? "PASS" : "FAIL", g_fails);
    rpt_line(RLINE);
    printf("LANDSCAPE %s (fails=%d)\n", g_fails == 0 ? "PASS" : "FAIL", g_fails);
    if (g_rpt >= 0) fclose(g_rpt);
    return g_fails;
}
