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

c/3d/g3_all.c · 119582 字节 · 下载原文

/* g3_all.c —— **VML 3D 号段 600–632 的逐号验收**(33 个号一个都不落)。
 * g3_all.c — **per-number acceptance for the VML 3D band 600–632** (all 33 numbers).
 *
 * 设计依据 `docs/VML3D接口.md`;参数语义以宿主实现为准
 * Design per the VML 3D interface doc; the host implementation is the source of truth for parameter semantics
 * (`WayCoder/UI/Shared/Vml3d.cs` 管立即模式 600–606、
 * (`WayCoder/UI/Shared/Vml3d.cs` covers immediate mode 600-606,
 *   `WayCoder/UI/Shared/Vml3dRetained.cs` 管保留模式 607–632)。
 *     `WayCoder/UI/Shared/Vml3dRetained.cs` covers retained mode 607-632).
 *
 * ## 这个程序要证明的那一件事
 * ## The one thing this program exists to prove
 *
 * **每个号都有一条「生效 / 没生效」分辨得出来的证据**,而不是「调了不崩」。
 * **Every number gets evidence that can tell "took effect" from "did not"**, not just "it did not crash".
 * 手法三种(对应任务书里的判据):
 * Three techniques (mirroring the criteria in the task brief):
 *   ① **有返回值的**:把返回值打出来,并断言它符合语义(合法 >0、越界/非法 <0);
 *     1) **Return values**: print the return value and assert it matches the semantics (valid >0, out-of-range/invalid <0);
 *   ② **改变画面的**:调 `ui_3d_viewport_get_pixel` 读**同一个像素**调用前后的值,两个值**必须不同**;
 *     2) **Changing the image**: read the **same pixel** with `ui_3d_viewport_get_pixel` before and after, and the two values **must differ**;
 *   ③ **反证**(本文件里有 5 条标 `REVERSE:` 的,另有「恢复后必须逐位相同」「非法参数必须被拒」等同类断言):把参数改成「应当无效果」的值,断言读数**不变**
 *     3) **Reverse tests** (5 lines labelled `REVERSE:` in this file, plus cousins like "restored => bit-identical" and "invalid arg rejected"): set the parameter to a value that should have no effect and assert the reading is **unchanged**
 *      —— 例:`ui_3d_viewport_set_depth` 连设两次同值必须逐位相同;`ui_3d_obj_set_spin` 转速给 0 则两个
 *           - e.g. setting `ui_3d_viewport_set_depth` to the same value twice must be bit-identical; with `ui_3d_obj_set_spin` at 0 the positions at two
 *      时刻的位置必须一模一样。**只测「变了」不测「不该变时没变」的判据会被巧合骗过。**
 *           instants must be exactly the same. **A criterion that only tests "it changed" and never "it did not change when it should not" is fooled by coincidence.**
 *
 * ## 跑法
 * ## How to run
 *   桌面无头(秒级闭环,别去打 APK):
 *     dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll \
 *            third_party/vml/Examples/c/g3_all.c --screen 393x757 --timeout 60
 *   手机:命令行页敲 `vml run examples/c/g3_all.c`
 *   On a phone: type `vml run examples/c/g3_all.c` in the command-line page.
 *
 * 机器判读:逐行 `PASS <号> <说明> <观测A> <观测B>` / `FAIL …` / `UNIMPL …`,
 * Machine-readable: one `PASS <num> <desc> <obsA> <obsB>` / `FAIL ...` / `UNIMPL ...` line per assertion,
 * 最后一行 `OK=<通过> FAIL=<失败> UNIMPL=<宿主未实现确认为预期> 号=<覆盖到的号数>`。
 * ending with a summary line `OK=<pass> FAIL=<fail> UNIMPL=<host-unsupported as expected> NUMS=<numbers covered>`.
 *
 * ## ⚠⚠ 一个**静默**的前端坑:`#include <math.h>` 不是可选项
 * ## ⚠⚠ A **silent** front-end trap: `#include <math.h>` is not optional
 *
 * 本文件第一版**漏了 `#include <math.h>`**(`<vml_ui.h>` 不代为包含)。
 * The first version of this file **was missing `#include <math.h>`** (`<vml_ui.h>` does not include it for you).
 * C89 的隐式声明让它**编得过、跑得起来、一个错都不报**,但 `sin()` 返回的是垃圾:
 * The C89 implicit declaration let it **compile, run and report no error at all**, yet `sin()` returned garbage:
 * 实测 `P2[3] = sin(0.5f)` 存进去是 **0.5**、`vcos(0) = sin(0 + π/2)` 得到 **1.5707963**。
 * measured, `P2[3] = sin(0.5f)` stored **0.5** and `vcos(0) = sin(0 + pi/2)` gave **1.5707963**.
 * 后果是 `gen_sphere` 产出的顶点全是同一个点 ⇒ **球体一个像素都不画**,
 * The consequence: `gen_sphere` produced vertices that are all the same point => **the sphere drew zero pixels**,
 * 而程序照样 `DONE=1` 退出。加了那一行之后逐字节全对(见文件末「实测记录」)。
 * while the program still exited `DONE=1`. One include line made every value byte-exact (see the measured record at the end).
 * ⇒ 凡是**几何**程序,把这一条当闸门:`sin/cos` 的结果直接打一个出来对表。
 * => For any **geometry** program, treat this as a gate: print one `sin/cos` result and check it against the table.
 * ⚠ **2026-10-04 复验仍成立**:漏掉 math.h 时 `float s = sin(0.5f)` 实测打印 **50000**(= 0.5,原样返回实参),
 * **Still holds, re-checked 2026-10-04**: without math.h, `float s = sin(0.5f)` measured printing **50000** (= 0.5, the argument echoed back),
 *   而带上 math.h 后 `sin(3.14159265f)` 得 **0**、`cos(3.14159265f)` 得 **-1000**(= -1.000)。**这一条别丢**。
 *     while with math.h `sin(3.14159265f)` gives **0** and `cos(3.14159265f)` gives **-1000** (= -1.000). **Do not drop this line.**
 */

#include <vml_ui.h>
#include <vml_3d.h>   /* 3D 接口库(600–634)—— 包装器在库里,本文件不再自己写 */
                      /* 3D API library (600-634) - the wrappers live in the library, this file does not redefine them. */
#include <math.h>     /* ⚠ 见文件头:漏了它 sin/cos 静默返回垃圾,程序不报错 */
                      /* See the file header: without it sin/cos silently return garbage and the program reports no error */
#include <stdio.h>

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

int g_ok;
int g_fail;
int g_unimpl;

/* 一条判据:cond 为真记通过,否则记失败;两个观测值原样打出来(判据要能被人复核)。 */
/* One assertion: cond true counts as pass, else fail; both observed values are printed verbatim (so a human can re-check). */
void ok1(char* tag, int cond, int a, int b) {
    if (cond) { g_ok = g_ok + 1; printf("PASS %s %d %d\n", tag, a, b); }
    else { g_fail = g_fail + 1; printf("FAIL %s %d %d\n", tag, a, b); }
}

/* 宿主**明确声明**尚未实现的号 —— 如实标注,不假装通过;但仍然断言它的返回码
 * A number the host **explicitly declares** as not yet implemented - marked honestly, never faked as a pass; but its return code is still asserted
 * (哪天真的实现了、返回码一变,这一行立刻红,不会悄悄溜过去)。
 * (the day it really is implemented the return code changes, this line goes red at once and cannot slip through silently). */
void unimpl(char* tag, int ret, int expect) {
    if (ret == expect) { g_unimpl = g_unimpl + 1; printf("UNIMPL %s %d\n", tag, ret); }
    else { g_fail = g_fail + 1; printf("FAIL %s %d expect=%d\n", tag, ret, expect); }
}

/* ══ syscall 外壳 ══════════════════════════════════════════════════════════
 * syscall wrappers
 * `g3_*` 是**裸 syscall**,不在随包的库里 ⇒ 每个程序自带薄封装(照 `solar3d.c`)。
 *   `g3_*` are **raw syscalls**, not in the bundled library => each program carries its own thin wrapper (as in `solar3d.c`).
 * ⚠ 返回值**必须**这样搬出来:`SYSCALL` 之后 `MOVE [_ret], R0`。
 *   The return value **must** be moved out like this: `MOVE [_ret], R0` after `SYSCALL`.
 *   `int g3_xxx(void) { return asm("SYSCALL #N"); }` 是**错的写法**。
 *     `int g3_xxx(void) { return asm("SYSCALL #N"); }` is the **wrong way**.
 */


/* ══ 小工具 ══════════════════════════════════════════════════════════════ */
/* == Small helpers == */

int sx(int sc) { return sc & 0xFFFF; }              /* 打包坐标:低 16 位 = x */
                                                    /* Packed coordinate: low 16 bits = x */
int sy(int sc) { return (sc >> 16) & 0xFFFF; }      /* 打包坐标:高 16 位 = y */
                                                    /* Packed coordinate: high 16 bits = y */
int rgb(int c) { return c & 0xFFFFFF; }
int alpha(int c) { return (c >> 24) & 0xFF; }
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 iabs(int v) { if (v < 0) return -v; return v; }

/* ⚠ 比色**必须两边都掩到 RGB**:读回来的是 `0xAARRGGBB`(A=0xFF ⇒ 负数),
 * Colour comparison **must mask both sides to RGB**: what comes back is `0xAARRGGBB` (A=0xFF => negative),
 *   而颜色常量也是负数 —— 拿 `rgb(t)` 去比一个没掩过的常量,两边永远不等。
 *     and the colour constants are negative too - compare `rgb(t)` against an unmasked constant and the two never match.
 *   本文件第一版就是这么错的(12 条判据集体假红,而画面其实全对)。
 *     The first version of this file made exactly this mistake (12 criteria went falsely red while the image was completely correct). */
int is_rgb(int c, int r, int g, int b) { return rgb(c) == (r * 65536 + g * 256 + b); }
int is_bg(int c) { return rgb(c) == 0x0F0F0F; }

/* 在 (cx,cy) 周围 ±r 的方框里数"亮度 > thr"的采样点。
 * ⚠ **步长 3 是有意的**:`ui_3d_viewport_get_pixel` 是**逐点一趟线程往返**(真机上 6561 次 = 33 秒,
 *   见 E137)⇒ r=18 时 13×13 = 169 次,是可接受的量级。(整帧读回那条批量入口在宿主内部,
 *   嘉宾这侧没有。)
 * Counts samples brighter than thr in a +-r box. The step of 3 is deliberate: get_pixel is one
 * thread round-trip per point (6561 points measured 33 s on a real device). */
int count_lum_over(int vp, int cx, int cy, int r, int thr) {
    int i; int j; int k; k = 0;
    for (j = -r; j <= r; j = j + 3)
        for (i = -r; i <= r; i = i + 3) {
            int c = ui_3d_viewport_get_pixel(vp, cx + i, cy + j);
            if (lum(c) > thr) k = k + 1;
        }
    return k;
}

/* 用十进制凑 ARGB,免得手算 `0xFFFF00FF` 的负数形式算错(实测踩过:把 0xFF000000 当成红,
 * Build ARGB from decimals, so nobody has to hand-compute the negative form of `0xFFFF00FF` (measured pitfall: taking 0xFF000000 for red
 * 结果断言追着一个「本来就该是黑」的像素跑了两轮)。
 *   and then chasing a pixel that "was supposed to be black" for two rounds). */
int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }

/* 带 alpha 的颜色(透明判据 `#641` 要用)—— `argb()` 把 alpha 固定成 255。
 * ⚠ 写法是 `(a - 256) * 16777216`:`a = 255` 时得 `-16777216`(与 `argb` 一致),
 *   `a = 128` 时得 `-2147483648`(= 0x80000000,**不会溢出** —— 直接写 `a * 16777216` 会)。 */
int argba(int a, int r, int g, int b) { return (a - 256) * 16777216 + r * 65536 + g * 256 + b; }

/* ~~⚠ 前端缺陷绕过:`cos()` 永远不返回负值 ⇒ 只用 `sin`。~~
 * ~~(historical) Front-end defect workaround: `cos()` never returned a negative value => use only `sin`.~~
 *   ~~`cos(x) == sin(x + π/2)` 是**等价的恒等式**,不是近似。~~
 *     ~~`cos(x) == sin(x + pi/2)` is an **exact identity**, not an approximation.~~
 * ✅ **已修好(2026-10-04 复验)**:实测 `cos(3.14159265f)` 的 `(int)(…*1000)` 得 **-1000**(= -1.000),
 * Fixed as of 2026-10-04 (re-verified): measured `(int)(cos(3.14159265f) * 1000)` gives **-1000** (= -1.000),
 *   即 `cos()` 现在正常返回负值。这个 `vcos` 包一层**照旧保留**(能跑就别动);
 *     so `cos()` now returns negatives normally. This `vcos` wrapper is **kept as is** (if it runs, do not touch it);
 *   **新写的代码直接 `cos()` 即可**(race3d.c / air3d.c / pool3d.c / tank3d.c 一直直接用 cos、一直是对的)。
 *     **new code can just call `cos()`** (race3d.c / air3d.c / pool3d.c / tank3d.c have always used cos directly and have always been right). */
float vcos(float x) { float t; t = sin(x + 1.5707963f); return t; }

/* ══ 网格装配(写进全局 MB,再整块交给 ui_3d_mesh_create —— 一次 syscall 传完整网格)════
 * Mesh assembly (build into the global MB, then hand the whole block to ui_3d_mesh_create - one syscall passes the full mesh)
 * 顶点 = 8 个 float32(pos3 + nrm3 + uv2 = 32 字节),紧随其后是 int32 索引。
 *   A vertex = 8 float32 (pos3 + nrm3 + uv2 = 32 bytes), followed immediately by int32 indices. */
float MB[4400];
int g_vn;
int g_in;

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

/* ⚠⚠ **带 uv 的顶点 —— 法线贴图的判据专用**。
 * 那份判据**必须**用一个四角 uv 互不相同的网格:法线贴图的 TBN 是用**屏幕空间导数**推的
 * (顶点只有 32 字节、没地方放切线),而 uv 全 0 时导数退化 ⇒ 实现**整个被跳过** ⇒
 * 判据"全绿"而实际一点作用都没有(与 620 那次"uv 全 0 ⇒ 贴图测不出来"是同一个教训)。 */
void mb_vertex_uv(int i, float px, float py, float pz, float u, float v) {
    int b = i * 8;
    MB[b + 0] = px; MB[b + 1] = py; MB[b + 2] = pz;
    MB[b + 3] = 0.0f; MB[b + 4] = 0.0f; MB[b + 5] = 1.0f;
    MB[b + 6] = u; MB[b + 7] = v;
}

/* ══ 带骨骼的顶点块(`#656` 的 56 字节布局)════════════════════════════════════════
 * Skinned vertex block (the `#656` 56-byte layout)
 *
 * 一个顶点 = **14 个 float32**:pos3 + nrm3 + uv2 + joints + padding + weights4
 *   A vertex = **14 float32**: pos3 + nrm3 + uv2 + joints + padding + weights4
 * ⚠ 从 `mb_*` 那套**另起一份**(新号 + 新布局),老网格一个字节都不动。
 * ⚠ This is a **separate** block from the mb_* set (a new number, a new layout); old meshes are untouched.
 *
 * ⚠⚠ **`joints` 是 4 个无符号字节、不是浮点**(浮点数组里那一格要按整型去写)——
 *   joints is 4 unsigned bytes, **not** floats, so that slot must be written as an integer:
 *   浮点下标 8 = joints(4 字节)+ 4 字节 padding(合成 8 字节 = 2 个 float)
 *   float index 8 = joints (4 bytes) + 4 bytes of padding (together 8 bytes = 2 floats)
 *   浮点下标 10..13 = weights(4 个 float)
 *   float index 10..13 = weights (4 floats)
 * ⚠ 本组判据里 joints 全 0(只有一根骨头),所以那一格写 0 就够。
 * ⚠ These criteria use a single bone, so writing 0 into the joints slot is enough. */
float SK[128];

/* 骨架矩阵的临时存放(1 根 = 16 个 float;656 那组判据用单位阵) */
/* Scratch space for skeleton matrices (one bone = 16 floats; the 656 group uses the identity). */
float SKM[16];

void sk_vertex(int i, float px, float py, float pz,
               float nx, float ny, float nz, float u, float v,
               float w0, float w1, float w2, float w3) {
    int b = i * 14;
    SK[b + 0] = px; SK[b + 1] = py; SK[b + 2] = pz;
    SK[b + 3] = nx; SK[b + 4] = ny; SK[b + 5] = nz;
    SK[b + 6] = u;  SK[b + 7] = v;
    SK[b + 8] = 0.0f;   /* joints(4 个 u8)+ padding —— 全 0 */
    SK[b + 9] = 0.0f;   /* padding */
    SK[b + 10] = w0; SK[b + 11] = w1; SK[b + 12] = w2; SK[b + 13] = w3;
}
/* 索引区紧跟在**顶点区之后**(`vcount * 14` 个 float 处)—— 这里的网格都是 4 个顶点。 */
/* The index block sits right after the vertex block (at vcount * 14 floats); every mesh here has 4 vertices. */
void sk_index(int k, int v) { int* ip; ip = (int*)&SK[0]; ip[4 * 14 + k] = v; }
int sk_ptr(void) { return (int)&SK[0]; }

/* ⚠ **一个 3×3 世界单位、放在 y = 0 平面上的方片**(法线朝 +Y)——见 656 那组判据。
 *   A 3-by-3 world-unit quad in the y = 0 plane (normal +Y) - see the 656 criteria group.
 * 用方片而不是三角形,是为了"画面被盖住"这件事有**大片**可读(读一个点就够,不必挑像素)。 */
/* A quad rather than a triangle so that "the screen is covered" is readable from almost any sample point. */
void gen_quad_sk(float h, float w0, float w1, float w2, float w3) {
    sk_vertex(0, 0 - h, 0.0f, 0 - h, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, w0, w1, w2, w3);
    sk_vertex(1, h, 0.0f, 0 - h, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, w0, w1, w2, w3);
    sk_vertex(2, h, 0.0f, h, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, w0, w1, w2, w3);
    sk_vertex(3, 0 - h, 0.0f, h, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, w0, w1, w2, w3);
    sk_index(0, 0); sk_index(1, 1); sk_index(2, 2);
    sk_index(3, 0); sk_index(4, 2); sk_index(5, 3);
}

/* ⚠ **同一块方片用老的 32 字节布局再建一份** —— 656 那组判据要拿它当**对照**
 *   The same quad again in the old 32-byte layout - the 656 criterion uses it as the control.
 * 目的是让"新布局被后端按对步长读了"这件事有一个**逐位**可比的基准(见那组判据的说明)。 */
void gen_quad_old(float h) {
    mb_vertex(0, 0 - h, 0.0f, 0 - h, 0.0f, 1.0f, 0.0f);
    mb_vertex(1, h, 0.0f, 0 - h, 0.0f, 1.0f, 0.0f);
    mb_vertex(2, h, 0.0f, h, 0.0f, 1.0f, 0.0f);
    mb_vertex(3, 0 - h, 0.0f, h, 0.0f, 1.0f, 0.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;
}

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

/* 一个**绕自身原点不对称**的三角:顶点 (0,0,0)(1,0,0)(0,1,0)。
 * A triangle **asymmetric about its own origin**: vertices (0,0,0) (1,0,0) (0,1,0).
 * 它存在的唯一理由是给 `ui_3d_obj_set_rot` 一条**像素级**证据:转 180°(绕 Z)之后,
 * Its only reason to exist is to give `ui_3d_obj_set_rot` **pixel-level** evidence: after rotating 180 deg (about Z),
 * 覆盖区从「原点的右上」翻到「原点的左下」,而它自己的原点投影**一动不动**
 * the covered area flips from the upper-right of the origin to the lower-left, while the projection of its own origin **does not move**,
 * —— 于是「读同一个像素、前后必须不同」这条判据才有的可读。
 *   and only then does the "read the same pixel, before and after must differ" criterion have something to read. */
void gen_tri(void) {
    mb_vertex(0, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f);
    mb_vertex(1, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f);
    mb_vertex(2, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
    g_vn = 3;
    mb_index(0, 0);
    mb_index(1, 1);
    mb_index(2, 2);
    g_in = 3;
}

/* ══ 计数(上限那一组判据要用;`*_free` 会连带释放,所以只能自己记)════════ */
/* == Counters (needed by the limit-test criteria; `*_free` releases things too, so we must track them ourselves) == */
int g_nmesh;
int g_nobj;

int nmesh(int p, int vc, int ic) {
    int r; r = ui_3d_mesh_create(p, vc, ic);
    if (r > 0) g_nmesh = g_nmesh + 1;
    return r;
}
int nobj(int vp, int m) {
    int r; r = ui_3d_obj_create(vp, m);
    if (r > 0) g_nobj = g_nobj + 1;
    return r;
}
/* ⚠ 释放必须走这里:`g_nobj` 是"现在还剩几个物体"的账,**漏减就是账不平**。
 * Releases must go through here: `g_nobj` is the "how many objects are left" book, and **a missed decrement unbalances it**.
 *   实测踩过:上限判据报"建出 260 个"(**当时的**物体上限是 256,真实是 256),差的那 4 个正是几次
 *     Measured pitfall: the limit criterion reported "260 created" (the object cap **back then** was 256, the truth was 256); the missing 4 were a few
 *   `ui_3d_obj_free` 没记账 —— 而**判据本身没错,错的是账**。
 *     `ui_3d_obj_free` calls that were not booked - and **the criterion was not wrong, the book was**. */
int fobj(int o) {
    int r; r = ui_3d_obj_free(o);
    if (r == 0) g_nobj = g_nobj - 1;
    return r;
}

/* 立即模式顶点批(`ui_3d_surface_verts` 用):3 个顶点 = 一个三角,每顶点 2 个 float32。 */
/* Immediate-mode vertex batch (used by `ui_3d_surface_verts`): 3 vertices = one triangle, 2 float32 per vertex. */
float VT[6];
/* 上限那一组判据用:攒下来的句柄 */
/* Used by the limit-test criteria: the handles accumulated so far */
int MS[80];

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

int main(void) {
    int sw; int sh; int cxs; int cys;
    int vp; int vp2; int vp3; int vp4;
    int m_sph; int m_tri; int m_tiny; int m_del;
    int o_near; int o_far; int o_side; int o_tri; int o_orb; int o_par; int o_kid;
    int o_pa; int o_ch; int o_tmp; int o_tmpa; int o_tmpb; int o_del;
    int m_uvq; int o_uvq;         /* 639 判据专用的带 uv 方片 */
    int s1; int s2; int n; int r; int t; int t2; int sc; int i; int k; int base;
    int px; int py; int c1; int c2;
    int img_a; int img_b;                                        /* 620 用的两个图像句柄 */
                                                                 /* the two image handles used by 620 */
    int a0; int a1; int b0; int b1; int L0;
    int f0; int f1; int f2; int off0; int off1;
    int o_cap;                              /* 615 第二条腿:建满之前先攒下的那一个句柄 */
                                            /* 615's second leg: the handle stashed before the cap was reached */
    int m_sk; int o_sk; int sk1; int m_tmp; /* 656–660 骨骼蒙皮的两个网格 + 骨架句柄 */
                                            /* 656-660: the two meshes (new/old layout) plus the skeleton handle */

    /* ⚠ 问尺寸必须用 **_ex**:`ui_scr_get_w()` 按**默认** chrome(要手柄)估算,
     * The size must be queried with **_ex**: `ui_scr_get_w()` estimates for the **default** chrome (with gamepad),
     *   与本程序 `ui_win_open_ex(..., gamepad=0)` 实际拿到的画布**对不上**,
     *     which does not match the canvas this program actually gets from `ui_win_open_ex(..., gamepad=0)`,
     *   视口会贴左上角(`solar3d.c` 实测踩过)。
     *     and the viewport sticks to the top-left corner (measured in `solar3d.c`). */
    ui_win_open_ex("3D 号段 600-632 逐号验收", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    sw = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    sh = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    if (sw <= 0) sw = 640;
    if (sh <= 0) sh = 640;
    printf("SCR=%dx%d\n", sw, sh);
    cxs = sw / 2;
    cys = sh / 2;

    /* 颜色常量自证(用 argb() 凑出来的值到底是不是那个色,打一行对表) */
    /* Colour-constant self-check (print one line so the argb() values can be checked against the table) */
    printf("COLOR bg=%d red=%d green=%d blue=%d magenta=%d\n",
           argb(15, 15, 15), argb(255, 0, 0), argb(0, 255, 0),
           argb(0, 0, 255), argb(255, 0, 255));

    /* ══════════════════════════════════════════════════════════════════════
     * 一、立即模式 600–606(每条都有自己的 offscreen 表面)
     * I. Immediate mode 600-606 (each number has its own offscreen surface)
     * ══════════════════════════════════════════════════════════════════════ */

    /* ── 600 ui_3d_surface_create(w,h) → surface ─────────────────────────────────────── */
    s1 = ui_3d_surface_create(64, 64);
    ok1("600 ui_3d_surface_create legal", s1 > 0, s1, 0);
    r = ui_3d_surface_create(0, 64);      ok1("600 init w=0 rejected", r < 0, r, 0);
    r = ui_3d_surface_create(64, -5);     ok1("600 init h<0 rejected", r < 0, r, 0);
    r = ui_3d_surface_create(9999, 64);   ok1("600 init w>MaxSide rejected", r < 0, r, 0);
    if (s1 <= 0) { printf("NO_3D_DEVICE=1\n"); printf("OK=%d FAIL=%d UNIMPL=%d\n", g_ok, g_fail, g_unimpl); return 0; }

    /* ── 601 ui_3d_surface_set_clear(surf, argb) ───────────────────────────────────────── */
    r = ui_3d_surface_set_clear(s1, argb(0, 0, 255));
    ok1("601 clear rc", r == 0, r, 0);
    t = ui_3d_surface_get_pixel(s1, 5, 5);
    ok1("601 clear->pixel==blue", is_rgb(t, 0, 0, 255), t, argb(0, 0, 255));
    r = ui_3d_surface_set_clear(s1, argb(0, 255, 0));
    t2 = ui_3d_surface_get_pixel(s1, 5, 5);
    ok1("601 clear again->pixel changed", is_rgb(t2, 0, 255, 0) && t2 != t, t2, t);

    /* ── 602 ui_3d_surface_tri(surf, x0,y0,x1,y1,x2,y2, argb)(8 个寄存器)────────── */
    /* -- 602 ui_3d_surface_tri(surf, x0,y0,x1,y1,x2,y2, argb) (8 registers) -- */
    ui_3d_surface_set_clear(s1, argb(0, 0, 255));
    t = ui_3d_surface_get_pixel(s1, 5, 5);
    r = ui_3d_surface_tri(s1, 0, 0, 60, 0, 0, 60, argb(255, 0, 0));
    ok1("602 tri rc", r == 0, r, 0);
    t2 = ui_3d_surface_get_pixel(s1, 5, 5);
    ok1("602 tri->pixel red (was blue)", is_rgb(t2, 255, 0, 0) && is_rgb(t, 0, 0, 255), t2, t);
    printf("NOTE 602 tri inside/outside = %d / %d\n", rgb(t2), rgb(ui_3d_surface_get_pixel(s1, 50, 50)));
    r = ui_3d_surface_tri(9999, 0, 0, 10, 0, 0, 10, argb(255, 255, 255));
    ok1("602 tri bad handle rejected", r < 0, r, 0);

    /* ── 603 ui_3d_surface_present(surf) ───────────────────────────────────────────── */
    r = ui_3d_surface_present(s1);
    ok1("603 present rc", r == 0, r, 0);
    t = ui_3d_surface_get_pixel(s1, 5, 5);
    ok1("603 present keeps triangle", is_rgb(t, 255, 0, 0), t, 0);
    /* **present = 帧边界**:批被消费掉 ⇒ 再 present 一次,画面上只剩清屏色。
     * **present = frame boundary**: the batch is consumed => present again and the image shows only the clear colour.
     * 这一条同时是「每帧命令缓冲语义」的判据(不清的话第二帧会把上一帧再画一遍)。
     *   This is also the criterion for "per-frame command-buffer semantics" (without a clear, the second frame would redraw the first). */
    r = ui_3d_surface_present(s1);
    t2 = ui_3d_surface_get_pixel(s1, 5, 5);
    ok1("603 present consumes batch (back to clear)", is_rgb(t2, 0, 0, 255), t2, t);
    r = ui_3d_surface_present(9999);
    ok1("603 present bad handle rejected", r < 0, r, 0);

    /* ── 604 ui_3d_surface_free(surf) ──────────────────────────────────────────────── */
    s2 = ui_3d_surface_create(32, 32);
    ok1("604 init s2", s2 > 0, s2, 0);
    r = ui_3d_surface_free(s2);
    ok1("604 free rc", r == 0, r, 0);
    r = ui_3d_surface_set_clear(s2, argb(255, 255, 255));
    ok1("604 clear after free rejected", r < 0, r, 0);
    r = ui_3d_surface_get_pixel(s2, 0, 0);
    ok1("604 readpixel after free rejected", r < 0, r, 0);
    r = ui_3d_surface_free(s2);
    ok1("604 double free rejected", r < 0, r, 0);

    /* ── 605 ui_3d_surface_get_pixel(surf, x, y) ───────────────────────────────────── */
    r = ui_3d_surface_get_pixel(s1, 64, 64);   ok1("605 x=w out of range rejected", r < 0, r, 0);
    r = ui_3d_surface_get_pixel(s1, -1, 0);    ok1("605 x=-1 out of range rejected", r < 0, r, 0);
    r = ui_3d_surface_get_pixel(s1, 0, 64);    ok1("605 y=h out of range rejected", r < 0, r, 0);
    r = ui_3d_surface_get_pixel(s1, 0, 0);
    ok1("605 in range A=0xFF", alpha(r) == 0xFF, r, 0);
    r = ui_3d_surface_get_pixel(9999, 0, 0);   ok1("605 bad handle rejected", r < 0, r, 0);

    /* ── 606 ui_3d_surface_verts(surf, ptr, count, argb)(float32 批量顶点)────────── */
    /* -- 606 ui_3d_surface_verts(surf, ptr, count, argb) (float32 vertex batch) -- */
    VT[0] = 0.0f;  VT[1] = 0.0f;
    VT[2] = 60.0f; VT[3] = 0.0f;
    VT[4] = 0.0f;  VT[5] = 60.0f;
    ui_3d_surface_set_clear(s1, argb(0, 0, 255));
    t = ui_3d_surface_get_pixel(s1, 5, 5);
    r = ui_3d_surface_verts(s1, (int)&VT[0], 3, argb(0, 255, 0));
    ok1("606 verts rc", r == 0, r, 0);
    t2 = ui_3d_surface_get_pixel(s1, 5, 5);
    ok1("606 verts->pixel green (was blue)", is_rgb(t2, 0, 255, 0) && is_rgb(t, 0, 0, 255), t2, t);
    r = ui_3d_surface_verts(s1, (int)&VT[0], 4, argb(255, 255, 255));
    ok1("606 count not multiple of 3 rejected", r < 0, r, 0);
    r = ui_3d_surface_verts(s1, (int)&VT[0], 0, argb(255, 255, 255));
    ok1("606 count=0 rejected", r < 0, r, 0);
    r = ui_3d_surface_verts(s1, 100000000, 3, argb(255, 255, 255));
    ok1("606 ptr out of range rejected", r < 0, r, 0);
    r = ui_3d_surface_verts(s1, (int)&VT[0], 393219, argb(255, 255, 255));
    ok1("606 count>MaxVertices rejected", r < 0, r, 0);

    /* ── 600 的上限:同时存在的表面数 = 8(定了就要报错,不许静默丢)─────── */
    /* -- 600's limit: concurrent surfaces = 8 (over the cap it must error, never silently drop) -- */
    n = 1;                              /* s1 已占一格 */
                                        /* s1 already takes one slot */
    r = 0;
    for (i = 0; i < 12; i = i + 1) {
        r = ui_3d_surface_create(8, 8);
        if (r > 0) { MS[n - 1] = r; n = n + 1; }
    }
    ok1("600 MaxSurfaces=8 enforced", n == 8, n, 8);
    ok1("600 over limit returns negative", r < 0, r, 0);
    for (i = 0; i < 7; i = i + 1) { ui_3d_surface_free(MS[i]); }
    r = ui_3d_surface_free(s1);                    /* 立即模式这一组用完就收 */
                                                   /* Clean up the immediate-mode group once done */
    ok1("604 free s1 rc", r == 0, r, 0);

    /* ══════════════════════════════════════════════════════════════════════
     * 二、保留模式 607–632
     * II. Retained mode 607-632
     *
     * 场景(相机**正对原点**:eye (0,0,600) → at (0,0,0),fov 60):
     *   Scene (camera **looking straight at the origin**: eye (0,0,600) -> at (0,0,0), fov 60):
     *   · o_near(红球 z=+120, scale 60)**先建**、o_far(绿球 z=-120, scale 60)后建
     *     - o_near (red ball z=+120, scale 60) created **first**, o_far (green ball z=-120, scale 60) after
     *     ⇒ 两者都正投影到屏幕正中;开深度 = 红(近的先画、远的过不了深度测试),
     *         => both project onto the screen centre; depth on = red (the near one is drawn first and the far one fails the depth test),
     *        关深度 = 绿(远的后画盖上来)。**先近后远的画序是判据能响的前提**,
     *               depth off = green (the far one is drawn later and covers it). **The near-then-far draw order is what makes the criterion fire**,
     *        反过来的话关掉深度测试结果不变(`docs/VML3D接口.md` §十 记的正是这条)。
     *               reversed, turning the depth test off changes nothing (that is exactly the note in section 10 of the 3D interface doc).
     *   · o_side(蓝球 x=+120)——颜色 / 显隐 / 光照的探针
     *     - o_side (blue ball x=+120) - the colour / visibility / lighting probe
     *   · o_tri(三角 x=-150,y=-120)——旋转的**像素级**探针
     *     - o_tri (triangle x=-150,y=-120) - the **pixel-level** probe for rotation
     *   · o_par(不可见,父子变换的父)+ o_kid(子,局部 x=+140)
     *     - o_par (invisible; the parent of the parent-child transform) + o_kid (child, local x=+140)
     *     ——旋转 / 缩放 / 自转都用 `ui_3d_obj_get_screen(o_kid)` 报的**数值**做判据
     *         - rotation / scale / spin are all judged by the **number** reported by `ui_3d_obj_get_screen(o_kid)`
     *     (比读像素稳,且与真正渲染**同源**:宿主里两者共用同一块矩阵)
     *         (steadier than reading pixels, and **same-source** as the real render: the host shares one matrix between them)
     *   · o_orb(z 轴上方公转)——公转探针
     *     - o_orb (orbiting above the z axis) - the orbit probe
     * ⚠ 没有 fit 之前,屏幕上 1 个世界单位 ≈ (sh/2)/tan(30°)/600 ≈ 1.09 像素。
     *   Before any fit, 1 world unit on screen is ~ (sh/2)/tan(30 deg)/600 ~ 1.09 pixels.
     * ══════════════════════════════════════════════════════════════════════ */

    gen_sphere(8, 10, 1.0f);
    m_sph = nmesh(mb_ptr(), g_vn, g_in);
    gen_tri();
    m_tri = nmesh(mb_ptr(), g_vn, g_in);
    printf("MESH sph=%d tri=%d vn=%d in=%d\n", m_sph, m_tri, g_vn, g_in);
    if (m_sph <= 0 || m_tri <= 0) {
        printf("NO_MESH=1\n");
        printf("OK=%d FAIL=%d UNIMPL=%d\n", g_ok, g_fail, g_unimpl);
        return 0;
    }

    /* ── 607 ui_3d_viewport_create(x,y,w,h,flags) → vp ────────────────────────────── */
    vp = ui_3d_viewport_create(0, 0, sw, sh, 0);
    ok1("607 viewport rc>0", vp > 0, vp, 0);
    if (vp <= 0) {
        printf("NO_3D_DEVICE=1\n");
        printf("OK=%d FAIL=%d UNIMPL=%d\n", g_ok, g_fail, g_unimpl);
        return 0;
    }
    printf("VP=%d\n", vp);
    r = ui_3d_viewport_create(0, 0, 0, 10, 0);       ok1("607 w=0 rejected", r < 0, r, 0);
    r = ui_3d_viewport_create(0, 0, 10, -1, 0);      ok1("607 h<0 rejected", r < 0, r, 0);
    r = ui_3d_viewport_create(0, 0, 99999, 10, 0);   ok1("607 w>MaxSide rejected", r < 0, r, 0);

    ui_3d_viewport_set_camera(vp, 0.0f, 0.0f, 600.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp, 60.0f, 1.0f, 8000.0f);
    ui_3d_viewport_set_bg(vp, argb(15, 15, 15));
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 0.42f);
    /* **手动时钟**:后面所有读数都要确定(时钟一动,带自转/公转的物体就会漂)。 */
    /* **Manual clock**: every reading below must be deterministic (with the clock running, spinning/orbiting objects drift). */
    ui_3d_viewport_set_time(vp, 0.0f);

    o_near = nobj(vp, m_sph);
    ui_3d_obj_set_color(o_near, argb(255, 0, 0));
    ui_3d_obj_set_pos(o_near, 0.0f, 0.0f, 120.0f);
    ui_3d_obj_set_scale(o_near, 60.0f);
    o_far = nobj(vp, m_sph);
    ui_3d_obj_set_color(o_far, argb(0, 255, 0));
    ui_3d_obj_set_pos(o_far, 0.0f, 0.0f, -120.0f);
    ui_3d_obj_set_scale(o_far, 60.0f);
    o_side = nobj(vp, m_sph);
    ui_3d_obj_set_color(o_side, argb(0, 0, 255));
    ui_3d_obj_set_pos(o_side, 120.0f, 0.0f, 0.0f);
    ui_3d_obj_set_scale(o_side, 40.0f);
    o_tri = nobj(vp, m_tri);
    ui_3d_obj_set_color(o_tri, argb(255, 0, 255));
    /* ⚠ 位置与探针步长都要**跟着视口尺寸走**:三角在屏上的腿长 ≈ h×0.058,
     * Both the position and the probe step must **follow the viewport size**: the triangle's on-screen leg length is ~ h x 0.058,
     *   写死 -150/-120 加 ±12 像素探针,在小视口上会探到三角外面、甚至探到屏幕外
     *     and a hard-coded -150/-120 with a +-12 pixel probe samples outside the triangle - or off screen - on a small viewport
     *   (实测 90x200 下那两条判据假红)。
     *     (measured: those two criteria went falsely red at 90x200). */
    ui_3d_obj_set_pos(o_tri, -100.0f, -100.0f, 0.0f);
    ui_3d_obj_set_scale(o_tri, 40.0f);
    o_orb = nobj(vp, m_sph);
    ui_3d_obj_set_color(o_orb, argb(255, 255, 0));
    ui_3d_obj_set_scale(o_orb, 30.0f);
    ui_3d_obj_set_orbit(o_orb, 0.0f, 150.0f, 0.0f, 140.0f, 4000.0f, 0.0f);
    /* 父**不可见**(只提供变换),这样它那个 1 单位的球不会在屏幕正中留一个点。 */
    /* The parent is **invisible** (it only provides a transform), so its 1-unit ball leaves no dot in the screen centre. */
    o_par = nobj(vp, m_sph);
    ui_3d_obj_set_color(o_par, argb(255, 255, 255));
    ui_3d_obj_set_visible(o_par, 0);
    ui_3d_obj_set_pos(o_par, 0.0f, -150.0f, 0.0f);
    o_kid = nobj(vp, m_sph);
    ui_3d_obj_set_color(o_kid, argb(255, 255, 0));
    ui_3d_obj_set_scale(o_kid, 25.0f);
    ui_3d_obj_set_pos(o_kid, 140.0f, 0.0f, 0.0f);
    ui_3d_obj_set_parent(o_kid, o_par);
    printf("OBJ near=%d far=%d side=%d tri=%d orb=%d par=%d kid=%d\n",
           o_near, o_far, o_side, o_tri, o_orb, o_par, o_kid);

    /* 场景自证:两个球确实正投影到屏幕正中(后面的深度判据靠这一条)。
     * Scene self-check: both balls really project onto the screen centre (the depth criterion below relies on this).
     * ⚠ 容差 1 像素是**必需的**:`ScreenX` 算的是 `(ndc+1)/2*w`,宽为奇数时
     *   The 1-pixel tolerance is **required**: `ScreenX` computes `(ndc+1)/2*w`, and for an odd width
     *   真实中心落在 x.5 上(411 宽 ⇒ 205.5 ⇒ 取整 206),而 `sw/2` 是 205。
     *     the true centre lands on x.5 (width 411 => 205.5 => rounds to 206), while `sw/2` is 205.
     *   实测:411x605 下死死比相等会假红两行。
     *     Measured: at 411x605, insisting on exact equality falsely reddens two lines. */
    sc = ui_3d_obj_get_screen(o_near);
    ok1("(setup) near sphere projects to center", iabs(sx(sc) - cxs) <= 1, sx(sc), cxs);
    sc = ui_3d_obj_get_screen(o_far);
    ok1("(setup) far sphere projects to center", iabs(sx(sc) - cxs) <= 1, sx(sc), cxs);

    /* ── 608 ui_3d_viewport_free(vp) ─────────────────────────────────────────────── */
    vp2 = ui_3d_viewport_create(0, 0, 256, 256, 0);
    vp3 = ui_3d_viewport_create(0, 0, 32, 32, 0);
    vp4 = ui_3d_viewport_create(0, 0, 32, 32, 0);
    ok1("607 vp2/vp3/vp4 created (4 is the cap)", vp2 > 0 && vp3 > 0 && vp4 > 0, vp2, vp4);
    r = ui_3d_viewport_create(0, 0, 32, 32, 0);
    ok1("607 MaxViewports=4 enforced", r < 0, r, 0);

    o_tmp = nobj(vp4, m_sph);
    r = ui_3d_viewport_free(vp4);
    g_nobj = g_nobj - 1;      /* vp_free 连带释放了 o_tmp(这正是下面那条判据要证的) */
                              /* vp_free released o_tmp as well (which is exactly what the criterion below proves) */
    ok1("608 vp_free rc", r == 0, r, 0);
    r = ui_3d_viewport_set_camera(vp4, 0.0f, 0.0f, 100.0f, 0.0f, 0.0f, 0.0f);
    ok1("608 camera after vp_free rejected", r < 0, r, 0);
    r = ui_3d_viewport_get_pixel(vp4, 0, 0);
    ok1("608 readpixel after vp_free rejected", r < 0, r, 0);
    r = ui_3d_obj_set_pos(o_tmp, 1.0f, 1.0f, 1.0f);
    ok1("608 vp_free cascades to its objects", r < 0, r, 0);
    r = ui_3d_viewport_free(vp4);
    ok1("608 double vp_free rejected", r < 0, r, 0);

    /* ── 609 ui_3d_viewport_set_camera(vp, ex,ey,ez, tx,ty,tz) ──────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_side); a0 = sx(sc);
    r = ui_3d_viewport_set_camera(vp, 200.0f, 0.0f, 600.0f, 0.0f, 0.0f, 0.0f);
    ok1("609 camera rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_side); a1 = sx(sc);
    ok1("609 move eye->projection changed", a1 != a0, a1, a0);
    ui_3d_viewport_set_camera(vp, 0.0f, 0.0f, 600.0f, 0.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_side);
    ok1("609 camera restored->projection identical", sx(sc) == a0, sx(sc), a0);
    r = ui_3d_viewport_set_camera(9999, 0.0f, 0.0f, 100.0f, 0.0f, 0.0f, 0.0f);
    ok1("609 bad handle rejected", r < 0, r, 0);

    /* ── 610 ui_3d_viewport_set_fov(vp, fov_deg, znear, zfar) ────────────────────────── */
    sc = ui_3d_obj_get_screen(o_side); b0 = sx(sc);
    r = ui_3d_viewport_set_fov(vp, 100.0f, 1.0f, 8000.0f);
    ok1("610 vp_fov rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_side); b1 = sx(sc);
    ok1("610 wider fov->projection closer to center",
        iabs(b1 - cxs) < iabs(b0 - cxs), b1, b0);
    ui_3d_viewport_set_fov(vp, 60.0f, 1.0f, 8000.0f);
    sc = ui_3d_obj_get_screen(o_side);
    ok1("610 fov restored->projection identical", sx(sc) == b0, sx(sc), b0);
    r = ui_3d_viewport_set_fov(vp, 0.0f, 1.0f, 8000.0f);     ok1("610 fov=0 rejected", r < 0, r, 0);
    r = ui_3d_viewport_set_fov(vp, 180.0f, 1.0f, 8000.0f);   ok1("610 fov>=179 rejected", r < 0, r, 0);
    r = ui_3d_viewport_set_fov(vp, 60.0f, 0.0f, 8000.0f);    ok1("610 near=0 rejected", r < 0, r, 0);
    r = ui_3d_viewport_set_fov(vp, 60.0f, 800.0f, 100.0f);   ok1("610 far<=near rejected", r < 0, r, 0);

    /* ── 611 ui_3d_viewport_set_bg(vp, argb) ─────────────────────────────────────────── */
    t = ui_3d_viewport_get_pixel(vp, 2, 2);
    ok1("611 corner pixel == bg", is_bg(t), t, 0);
    r = ui_3d_viewport_set_bg(vp, argb(63, 63, 63));
    ok1("611 vp_bg rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, 2, 2);
    ok1("611 change bg->corner pixel follows", is_rgb(t2, 63, 63, 63), t2, t);
    ui_3d_viewport_set_bg(vp, argb(15, 15, 15));
    t2 = ui_3d_viewport_get_pixel(vp, 2, 2);
    ok1("611 bg restored", is_bg(t2), t2, 0);
    r = ui_3d_viewport_set_bg(9999, argb(0, 0, 0));   ok1("611 bad handle rejected", r < 0, r, 0);

    /* ── 612 ui_3d_viewport_set_light(vp, dx,dy,dz, argb, ambient) ──────────────────────── */
    ui_3d_obj_set_color(o_side, argb(255, 0, 0));
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 0.42f);
    sc = ui_3d_obj_get_screen(o_side); px = sx(sc); py = sy(sc);
    t = ui_3d_viewport_get_pixel(vp, px, py);                       /* L0 基线 */
                                                                    /* L0 baseline */
    L0 = t;                                                /* 留一份:下面 t 还要被复用 */
                                                           /* Keep a copy: t gets reused below */
    ok1("612 baseline: red object reads red-ish", rch(t) > gch(t) + 60 && rch(t) > bch(t) + 60, rch(t), gch(t));
    r = ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 0.0f);
    ok1("612 light rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);                      /* L1 环境光 0 */
                                                                    /* L1 ambient 0 */
    ok1("612 ambient 0.42->0 darkens", lum(t2) < lum(t), lum(t2), lum(t));
    /* 光方向改成**与法线垂直**(这个球面朝相机那一点的法线约 (-0.196,0,0.981))
     * Light direction changed to be **perpendicular to the normal** (the normal of this sphere at the point facing the camera is about (-0.196,0,0.981))
     * ⇒ dot=0 ⇒ 只剩环境光(此刻已是 0)⇒ 更暗。证明光**方向**真的参与了着色。
     *   => dot=0 => only ambient remains (which is already 0) => darker. Proves the light **direction** really takes part in shading. */
    ui_3d_viewport_set_light(vp, 0.98f, 0.0f, 0.196f, argb(255, 255, 255), 0.0f);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);                      /* L2 垂直入射 */
                                                                    /* L2 perpendicular incidence */
    ok1("612 light perpendicular to normal->darker still", lum(c1) < lum(t2), lum(c1), lum(t2));
    /* 环境光的**上下钳位**(`Math.Clamp(amb,0,1)`):用暗色物体看,饱和色看不出钳位。
     * The **clamping of ambient** (`Math.Clamp(amb,0,1)`): look at a dark object, because a saturated colour hides the clamp.
     * 1.0 与 2.0 必须**逐位相同**(不钳的话 2.0 会更亮);0.0 与 -1.0 同理。
     *   1.0 and 2.0 must be **bit-identical** (without the clamp 2.0 would be brighter); likewise 0.0 and -1.0. */
    ui_3d_obj_set_color(o_side, argb(16, 16, 16));
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 1.0f);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 2.0f);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("612 ambient clamped at 1.0 (1.0 == 2.0)", t == t2, t2, t);
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 0.0f);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), -1.0f);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("612 ambient clamped at 0.0 (0.0 == -1.0)", t == t2, t2, t);
    /* 复原 */
    /* Restore */
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 0.42f);
    ui_3d_obj_set_color(o_side, argb(255, 0, 0));
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("612 light restored->pixel identical", t2 == L0 && rch(t2) > gch(t2) + 60, t2, L0);
    r = ui_3d_viewport_set_light(9999, 0.0f, 1.0f, 0.0f, argb(255, 255, 255), 0.42f);
    ok1("612 bad handle rejected", r < 0, r, 0);

    /* ── 613 ui_3d_mesh_create(ptr, vcount, icount) → mesh ────────────────────────── */
    gen_tri();
    r = ui_3d_mesh_create(mb_ptr(), 0, 3);          ok1("613 vcount=0 rejected", r < 0, r, 0);
    r = ui_3d_mesh_create(mb_ptr(), 3, 0);          ok1("613 icount=0 rejected", r < 0, r, 0);
    r = ui_3d_mesh_create(mb_ptr(), 3, 4);          ok1("613 icount%3!=0 rejected", r < 0, r, 0);
    r = ui_3d_mesh_create(99999900, 3, 3);          ok1("613 ptr out of range rejected", r < 0, r, 0);
    r = ui_3d_mesh_create(mb_ptr(), VML3D_MAX_MESH_VERTICES + 1, 3);
    ok1("613 vcount>MaxMeshVertices rejected", r < 0, r, 0);
    /* 单网格的**两个**上限是一对常量:只守其中一个,另一个被改回旧值也永远不红。
     * Both per-mesh caps are one pair of constants - guarding only one leaves the other silently free.
     * ⚠ `icount` 的探针必须是 **3 的正倍数**,否则先被上面那条 `icount%3!=0` 拦掉。
     * The icount probe must be a **positive multiple of 3**, or the `icount%3!=0` rule above catches it first.
     * 那样判据就成了"任何负数都过"(实测:1920001 会返回 -2),**测不到上限那一关**。
     * Then the criterion degenerates to "any negative passes" (measured: 1920001 returns -2), never touching the cap.
     * ⚠ 期望值走**头文件里的常量**(`VML3D_MAX_MESH_*`,`#include <vml_3d.h>` 就有)而不是字面值。
     * The expected values come from the **header constants** (`VML3D_MAX_MESH_*` via `#include <vml_3d.h>`).
     * 头文件与宿主曾经各写各的(C 侧 200000/600000、宿主 640000/1920000),于是"编得过、却不按文档的上限拦",
     * The header and the host used to carry separate numbers (200000/600000 here, 640000/1920000 there), so code
     * ⚠ 这条判据就是那件事的守卫(2026-10-08 修;同期把 C 侧那两个数改成与宿主一致)。
     * compiled yet the documented cap was not the enforced one - this criterion guards exactly that (fixed 2026-10-08).
     * ⚠ 量出来的错法:改回字面值 200001 时,这条判据会读到 `3`(一个**正的网格句柄**)而不是负数 ⇒ 立刻红。
     * Measured failure mode: with the literal 200001 back in place this reads `3` (a **positive mesh handle**), not a negative. */
    r = ui_3d_mesh_create(mb_ptr(), 3, VML3D_MAX_MESH_INDICES + 3);
    ok1("613 icount>MaxMeshIndices rejected", r < 0, r, 0);
    m_tiny = nmesh(mb_ptr(), 3, 3);
    ok1("613 mesh rc>0", m_tiny > 0, m_tiny, g_nmesh);
    /* 网格数上限 64:从当前数起一路建到失败,断言总数正好 64 */
    /* Mesh cap 64: keep creating until it fails, and assert the total is exactly 64 */
    base = g_nmesh;
    k = 0;
    r = 0;
    for (i = 0; i < 200; i = i + 1) {
        r = ui_3d_mesh_create(mb_ptr(), 3, 3);
        if (r > 0) { k = k + 1; MS[k] = r; g_nmesh = g_nmesh + 1; }
    }
    ok1("613 MaxMeshes=64 enforced", (base + k) == 64, base + k, 64);
    ok1("613 over limit returns negative", r < 0, r, 0);
    for (i = 1; i <= k; i = i + 1) { ui_3d_mesh_free(MS[i]); g_nmesh = g_nmesh - 1; }
    for (i = 0; i < 80; i = i + 1) { MS[i] = 0; }

    /* ── 614 ui_3d_mesh_free(mesh) ─────────────────────────────────────────── */
    m_del = 0;
    gen_tri();
    m_del = nmesh(mb_ptr(), 3, 3);
    o_del = nobj(vp3, m_del);
    r = ui_3d_mesh_free(m_del);
    ok1("614 mesh_free rc", r == 0, r, 0);
    g_nmesh = g_nmesh - 1;
    g_nobj = g_nobj - 1;      /* 同上:网格释放连带把 o_del 收走了 */
                              /* Same as above: freeing the mesh took o_del with it */
    r = ui_3d_obj_create(vp3, m_del);
    ok1("614 ui_3d_obj_create with freed mesh rejected", r < 0, r, 0);
    r = ui_3d_obj_set_pos(o_del, 0.0f, 0.0f, 0.0f);
    ok1("614 mesh_free cascades to its objects", r < 0, r, 0);
    r = ui_3d_mesh_free(m_del);
    ok1("614 double mesh_free rejected", r < 0, r, 0);

    /* ── 615 ui_3d_obj_create(vp, mesh) → obj ─────────────────────────────────────── */
    ok1("615 obj handles all >0", o_near > 0 && o_side > 0 && o_kid > 0, o_near, o_kid);
    r = ui_3d_obj_create(9999, m_sph);   ok1("615 bad vp rejected", r < 0, r, 0);
    r = ui_3d_obj_create(vp, 9999);      ok1("615 bad mesh rejected", r < 0, r, 0);

    /* ── 616 ui_3d_obj_set_pos(obj, x,y,z) ─────────────────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_side); a0 = sx(sc); a1 = sy(sc);
    r = ui_3d_obj_set_pos(o_side, -120.0f, 0.0f, 0.0f);
    ok1("616 obj_pos rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_side); b0 = sx(sc);
    ok1("616 move to -x mirrors screen x",
        b0 != a0 && b0 < cxs && a0 > cxs && iabs(iabs(b0 - cxs) - iabs(a0 - cxs)) <= 2, b0, a0);
    ui_3d_obj_set_pos(o_side, 120.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_side);
    ok1("616 restored->projection identical", sx(sc) == a0 && sy(sc) == a1, sx(sc), a0);
    r = ui_3d_obj_set_pos(9999, 0.0f, 0.0f, 0.0f);
    ok1("616 bad handle rejected", r < 0, r, 0);
    px = sx(sc); py = sy(sc);                    /* o_side 的投影中心,后面复用 */
                                                 /* o_side's projected centre, reused below */

    /* ── 617 ui_3d_obj_set_rot(obj, ax,ay,az) ──────────────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid); k = sx(sc);
    r = ui_3d_obj_set_rot(o_par, 0.0f, 0.0f, 180.0f);
    ok1("617 obj_rot rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_kid); n = sx(sc);
    ok1("617 parent rot 180deg->child mirrors", n < cxs && k > cxs && iabs(iabs(n - cxs) - iabs(k - cxs)) <= 2, n, k);
    sc = ui_3d_obj_get_screen(o_par);
    ok1("617 rotation is about own origin (origin unmoved)", iabs(sx(sc) - cxs) <= 1, sx(sc), cxs);
    ui_3d_obj_set_rot(o_par, 0.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    ok1("617 rot restored->projection identical", sx(sc) == k, sx(sc), k);
    /* 像素级:三角绕自身原点转 180°,覆盖区从「右上」翻到「左下」。
     * Pixel level: the triangle rotates 180 deg about its own origin, and the covered area flips from upper-right to lower-left.
     * 探针步长按视口高取(三角腿长 ≈ h×0.058 ⇒ 步长取 h/60 ≈ 0.017h,稳稳落在三角内)。
     *   The probe step follows the viewport height (leg length ~ h x 0.058 => step h/60 ~ 0.017h, safely inside the triangle). */
    sc = ui_3d_obj_get_screen(o_tri); a0 = sx(sc); a1 = sy(sc);
    px = sh / 60;
    if (px < 3) px = 3;
    t  = ui_3d_viewport_get_pixel(vp, a0 + px, a1 - px);      /* 右上 */
                                                              /* upper right */
    t2 = ui_3d_viewport_get_pixel(vp, a0 - px, a1 + px);      /* 左下 */
                                                              /* lower left */
    ui_3d_obj_set_rot(o_tri, 0.0f, 0.0f, 180.0f);
    c1 = ui_3d_viewport_get_pixel(vp, a0 + px, a1 - px);
    c2 = ui_3d_viewport_get_pixel(vp, a0 - px, a1 + px);
    ui_3d_obj_set_rot(o_tri, 0.0f, 0.0f, 0.0f);
    printf("NOTE 617 tri UR/DL before=%08X/%08X after=%08X/%08X\n", rgb(t), rgb(t2), rgb(c1), rgb(c2));
    ok1("617 pixel: UR covered before rot", !is_bg(t), t, 0);
    ok1("617 pixel: DL background before rot", is_bg(t2), t2, 0);
    ok1("617 pixel: UR background after 180deg", is_bg(c1), c1, 0);
    ok1("617 pixel: DL covered after 180deg", !is_bg(c2), c2, 0);
    r = ui_3d_obj_set_rot(9999, 0.0f, 0.0f, 0.0f);
    ok1("617 bad handle rejected", r < 0, r, 0);

    /* ── 618 ui_3d_obj_set_scale(obj, s) ───────────────────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid); off0 = iabs(sx(sc) - cxs);
    r = ui_3d_obj_set_scale(o_par, 2.0f);
    ok1("618 obj_scale rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_kid); off1 = iabs(sx(sc) - cxs);
    ok1("618 parent scale x2 -> child offset x2", iabs(off1 - off0 * 2) <= 3, off1, off0);
    ui_3d_obj_set_scale(o_par, 1.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    ok1("618 scale restored->projection identical", iabs(sx(sc) - cxs) == off0, sx(sc), off0);
    r = ui_3d_obj_set_scale(o_par, 0.0f);    ok1("618 scale=0 rejected", r < 0, r, 0);
    r = ui_3d_obj_set_scale(o_par, -1.0f);   ok1("618 scale<0 rejected", r < 0, r, 0);
    r = ui_3d_obj_set_scale(9999, 1.0f);     ok1("618 bad handle rejected", r < 0, r, 0);

    /* ── 619 ui_3d_obj_set_color(obj, argb) ────────────────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_side); px = sx(sc); py = sy(sc);
    ui_3d_obj_set_color(o_side, argb(255, 0, 0));
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("619 red->R dominant", rch(t) > gch(t) + 60 && rch(t) > bch(t) + 60, rch(t), gch(t));
    r = ui_3d_obj_set_color(o_side, argb(0, 255, 0));
    ok1("619 obj_color rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("619 green->G dominant", gch(t2) > rch(t2) + 60 && gch(t2) > bch(t2) + 60, gch(t2), rch(t2));
    ok1("619 color change->pixel really changed", t2 != t, t2, t);
    ui_3d_obj_set_color(o_side, argb(0, 0, 255));
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("619 blue->B dominant", bch(t) > rch(t) + 60 && bch(t) > gch(t) + 60, bch(t), rch(t));
    r = ui_3d_obj_set_color(9999, argb(255, 255, 255));
    ok1("619 bad handle rejected", r < 0, r, 0);

    /* ── 620 ui_3d_obj_set_tex(obj, image_handle) —— 3D 贴图 ──────────────────── */
    /* -- 620 ui_3d_obj_set_tex(obj, image_handle) -- 3D texturing -- */
    /*
     * ⚠⚠ **这里必须用「纯色图」而不是花图** —— 本程序的网格 `o_side` 的 uv 是
     *   `mb_vertex` 写死的 **0**(见文件头那段),所以无论贴什么图,采样的**永远是 texel(0,0)**。
     *   贴一张棋盘/四方格的话,"uv 属性根本没接上"与"接上了"**读出来完全一样** ⇒ 判据恒假绿。
     *   纯色图 + 断言**精确通道**(红主导不是"颜色变了")才分辨得出来。
     *   真正验"uv 有变化"的那条判据在 `tex_demo.c`(它带一个四角 uv 互不相同的格子)。
     * ⚠ This grid's uv is hard-coded to 0, so a checkerboard would read identically to a broken
     *   uv attribute. A solid-colour image plus an **exact channel** assertion is what discriminates.
     */
    ui_2d_rect(4, 4, 24, 24, argb(255, 0, 0), 1, 0, 0);        /* 纯红 */
    ui_2d_present();
    img_a = ui_2d_get_image(4, 4, 24, 24);
    ok1("620 get_image -> handle", img_a > 0, img_a, 0);

    /* ⚠⚠ **贴图是"乘性 tint"(物体色 × 纹素色),所以物体必须先设成白**。
     *    这一段的第一版忘了这一步:当时 `o_side` 还是 619 段留下的**蓝**,
     *    蓝 × 红 = **黑** —— 而那是**完全正确的行为**,读到 `0xFF000000` 时我
     *    先去怀疑纹理上传坏了,绕了一圈才发现错的是判据。
     *    ⇒ 白是乘法中性元,`ui_3d_obj_set_color(obj, 0xFFFFFFFF)` 之后纹理才原样显示。 */
    /* ⚠⚠ Texturing is a **multiplicative tint** (object colour x texel), so the object must be
     *    white first. The first version of this block forgot that: o_side was still **blue** from
     *    the 619 block and blue x red = **black** - which is entirely correct behaviour. Reading
     *    0xFF000000 sent me hunting for a texture-upload bug; the criterion was what was wrong. */
    r = ui_3d_obj_set_color(o_side, argb(255, 255, 255));
    ok1("620 white tint base rc", r == 0, r, 0);

    r = ui_3d_obj_set_tex(o_side, img_a);
    ok1("620 obj_set_tex rc", r == 0, r, 0);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("620 tex(red)->R dominant", rch(t) > gch(t) + 60 && rch(t) > bch(t) + 60, rch(t), bch(t));

    /* 换成纯绿:**另一张图** ⇒ 读数必须跟着换(证明"真的换了来源",不是碰巧红) */
    ui_2d_rect(4, 4, 24, 24, argb(0, 255, 0), 1, 0, 0);        /* 纯绿盖掉红 */
    ui_2d_present();
    img_b = ui_2d_get_image(4, 4, 24, 24);
    ok1("620 2nd get_image -> handle", img_b > 0, img_b, 0);
    r = ui_3d_obj_set_tex(o_side, img_b);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("620 swap tex->G dominant", gch(t2) > rch(t2) + 60 && gch(t2) > bch(t2) + 60, gch(t2), rch(t2));
    ok1("620 swap tex->pixel really changed", t2 != t, t2, t);

    /* 取消贴图(传 0)⇒ 回到物体自己的颜色(此时是**白**)。
     * 然后把颜色**恢复成蓝** —— 下一段 621 的起点是"此刻 o_side 是蓝的"。 */
    r = ui_3d_obj_set_tex(o_side, 0);
    ok1("620 set_tex(0) rc", r == 0, r, 0);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    /* ⚠ 判据是「三通道**严格相等**且不为黑」,**不是**「>200」:o_side 没开自发光,
     *   白会被光照压暗(实测 141/141/141)。而"三通道相等"正是"白 × 光照"的签名 ——
     *   比一个想当然的亮度阈值稳得多(阈值会随灯的方向/环境光比例漂)。
     * The criterion is "three channels exactly equal and not black", not ">200": without emissive
     * the white gets dimmed by lighting (measured 141/141/141), and equality is the signature. */
    ok1("620 untint->back to tint colour (R==G==B)",
        rch(t) == gch(t) && gch(t) == bch(t) && rch(t) > 60, rch(t), bch(t));

    r = ui_3d_obj_set_color(o_side, argb(0, 0, 255));
    ok1("620 restore blue rc", r == 0, r, 0);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("620 restored->blue again", bch(t) > rch(t) + 60 && bch(t) > gch(t) + 60, bch(t), rch(t));

    r = ui_3d_obj_set_tex(9999, img_a);
    ok1("620 bad handle rejected first", r < 0, r, 0);
    r = ui_3d_obj_set_tex(o_side, 999999);
    ok1("620 bad image rejected", r < 0, r, 0);

    /* ── 621 ui_3d_obj_set_visible(obj, on) ────────────────────────────────────── */
    t = ui_3d_viewport_get_pixel(vp, px, py);                    /* 此刻 o_side 是蓝的 */
                                                                 /* o_side is blue at this point */
    ok1("621 baseline visible (not bg)", !is_bg(t), t, 0);
    r = ui_3d_obj_set_visible(o_side, 0);
    ok1("621 obj_visible rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("621 hide->that pixel becomes bg", is_bg(t2), t2, t);
    ui_3d_obj_set_visible(o_side, 1);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("621 show again->pixel identical", c1 == t, c1, t);
    ui_3d_obj_set_visible(o_side, 2);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("621 on=2 also means visible", c1 == t, c1, t);
    r = ui_3d_obj_set_visible(9999, 0);
    ok1("621 bad handle rejected", r < 0, r, 0);

    /* ── 622 ui_3d_obj_free(obj) ───────────────────────────────────────────── */
    o_tmp = nobj(vp2, m_sph);
    r = fobj(o_tmp);
    ok1("622 obj_free rc", r == 0, r, 0);
    r = ui_3d_obj_set_pos(o_tmp, 0.0f, 0.0f, 0.0f);
    ok1("622 pos after obj_free rejected", r < 0, r, 0);
    r = ui_3d_obj_get_screen(o_tmp);
    ok1("622 screen after obj_free rejected", r < 0, r, 0);
    r = ui_3d_obj_free(o_tmp);
    ok1("622 double obj_free rejected", r < 0, r, 0);
    /* free 父 ⇒ 子不该指向一个没了的父(不崩、仍可读数) */
    /* Free the parent => the child must not point at a gone parent (no crash, still readable) */
    o_tmpa = nobj(vp2, m_sph);
    o_tmpb = nobj(vp2, m_sph);
    ui_3d_obj_set_parent(o_tmpb, o_tmpa);
    fobj(o_tmpa);
    r = ui_3d_obj_get_screen(o_tmpb);
    ok1("622 child survives parent free (no dangling)", r >= 0, r, 0);
    fobj(o_tmpb);

    /* ── 623 ui_3d_obj_set_spin(obj, ax,ay,az)(度/秒,宿主每帧自动转)──────────── */
    /* -- 623 ui_3d_obj_set_spin(obj, ax,ay,az) (degrees/second, the host spins it every frame) -- */
    ui_3d_obj_set_rot(o_par, 0.0f, 0.0f, 0.0f);
    r = ui_3d_obj_set_spin(o_par, 0.0f, 180.0f, 0.0f);
    ok1("623 obj_spin rc", r == 0, r, 0);
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid); k = sx(sc);
    ui_3d_viewport_set_time(vp, 1000.0f);                /* 180 度/秒 × 1 秒 = 180° */
                                                         /* 180 deg/s x 1 s = 180 deg */
    sc = ui_3d_obj_get_screen(o_kid); n = sx(sc);
    ok1("623 spin 180deg/s at t=1s->mirrored", n < cxs && k > cxs && iabs(iabs(n - cxs) - iabs(k - cxs)) <= 2, n, k);
    /* 反证:转速 0 ⇒ 两个时刻必须一模一样(否则「位置变了」可能只是时钟在走) */
    /* Reverse test: spin 0 => the two instants must be exactly the same (otherwise "the position changed" might just be the clock running) */
    ui_3d_obj_set_spin(o_par, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid); a0 = sx(sc);
    ui_3d_viewport_set_time(vp, 2500.0f);
    sc = ui_3d_obj_get_screen(o_kid); a1 = sx(sc);
    ok1("623 REVERSE: spin=0 -> two times identical", a0 == a1, a1, a0);
    r = ui_3d_obj_set_spin(9999, 0.0f, 0.0f, 0.0f);
    ok1("623 bad handle rejected", r < 0, r, 0);

    /* ── 624 ui_3d_obj_set_orbit(obj, cx,cy,cz, r, period_ms, phase_deg) ───────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_orb); a0 = sx(sc); a1 = sy(sc);
    ok1("624 phase 0 starts to the right", a0 > cxs, a0, cxs);
    ui_3d_viewport_set_time(vp, 2000.0f);                /* 半周期 */
                                                         /* Half a period */
    sc = ui_3d_obj_get_screen(o_orb); b0 = sx(sc);
    ok1("624 half period->mirrored", b0 < cxs && iabs(iabs(b0 - cxs) - iabs(a0 - cxs)) <= 2, b0, a0);
    ok1("624 orbit keeps y (circle in XZ plane)", iabs(sy(sc) - a1) <= 1, sy(sc), a1);
    r = ui_3d_obj_set_orbit(o_orb, 0.0f, 0.0f, 0.0f, -1.0f, 4000.0f, 0.0f);
    ok1("624 negative radius rejected", r < 0, r, 0);
    /* 反证:周期 0 = 不动;同时相位必须真的被用上(否则「不动」可能只是相位也被忽略)。
     * Reverse test: period 0 = no motion; and the phase must really be used (otherwise "no motion" might just mean the phase is ignored too).
     * ⚠ 相位挑 **90°** 而不是 33°:90° 时物体转到 (0,150,140)、屏幕 x 落到正中,
     *   The phase is picked as **90 deg** rather than 33 deg: at 90 deg the object moves to (0,150,140) and screen x lands in the centre,
     *   与相位 0 的 349 差 150 像素 —— 一眼可辨。33° 只差 6 像素(x 变小但同时 z 变近,
     *     150 pixels away from 349 at phase 0 - obvious at a glance. At 33 deg it differs by only 6 pixels (x shrinks but z also gets nearer,
     *   透视正好把大部分差异抵掉),那种"差一点点"的判据最容易在边界上翻。
     *     and perspective cancels most of the difference); that kind of "barely differs" criterion flips at the boundary most easily. */
    r = ui_3d_obj_set_orbit(o_orb, 0.0f, 150.0f, 0.0f, 140.0f, 0.0f, 90.0f);
    ok1("624 obj_orbit rc", r == 0, r, 0);
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_orb); c1 = sx(sc);
    ui_3d_viewport_set_time(vp, 2000.0f);
    sc = ui_3d_obj_get_screen(o_orb); c2 = sx(sc);
    ok1("624 REVERSE: period=0 -> two times identical", c1 == c2, c2, c1);
    /* 相位 90° 时物体在 x 上归零 ⇒ 屏幕 x 落到正中;相位 0 时偏在一边。
     * At phase 90 deg the object's x is zero => screen x lands in the centre; at phase 0 it sits off to one side.
     * 用**相对**判据(四分之一以内)而不是绝对像素:偏移量本身随视口高度变
     *   Use a **relative** criterion (within a quarter) rather than absolute pixels: the offset itself scales with viewport height
     * (800x480 只有 97 px,写死 >100 会假红)。
     *   (only 97 px at 800x480, so a hard-coded >100 falsely reddens). */
    ok1("624 REVERSE: phase 90deg really applied",
        iabs(c1 - cxs) * 4 < iabs(a0 - cxs), c1, a0);
    ui_3d_obj_set_orbit(o_orb, 0.0f, 150.0f, 0.0f, 140.0f, 4000.0f, 0.0f);
    r = ui_3d_obj_set_orbit(9999, 0.0f, 0.0f, 0.0f, 10.0f, 1000.0f, 0.0f);
    ok1("624 bad handle rejected", r < 0, r, 0);

    /* ── 625 ui_3d_obj_get_screen(obj) → 打包坐标(低 16 位 x / 高 16 位 y)────── */
    /* -- 625 ui_3d_obj_get_screen(obj) -> packed coordinate (low 16 bits x / high 16 bits y) -- */
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_side);
    ok1("625 packed x within viewport", sx(sc) >= 0 && sx(sc) < sw, sx(sc), sw);
    ok1("625 packed y within viewport", sy(sc) >= 0 && sy(sc) < sh, sy(sc), sh);
    ok1("625 high half decodes to y", ((sc >> 16) & 0xFFFF) == sy(sc), (sc >> 16) & 0xFFFF, sy(sc));
    r = ui_3d_obj_get_screen(9999);
    ok1("625 bad handle rejected", r < 0, r, 0);

    /* ── 626 ui_3d_viewport_get_pixel(vp, x, y) ──────────────────────────────────── */
    r = ui_3d_viewport_get_pixel(vp, sw, 0);   ok1("626 x=w rejected", r < 0, r, 0);
    r = ui_3d_viewport_get_pixel(vp, -1, 0);   ok1("626 x=-1 rejected", r < 0, r, 0);
    r = ui_3d_viewport_get_pixel(vp, 0, sh);   ok1("626 y=h rejected", r < 0, r, 0);
    r = ui_3d_viewport_get_pixel(vp, 0, 0);
    ok1("626 in range A=0xFF", alpha(r) == 0xFF, r, 0);
    t = ui_3d_viewport_get_pixel(vp, 5, 5);
    t2 = ui_3d_viewport_get_pixel(vp, 5, 5);
    ok1("626 REVERSE: same pixel twice identical", t == t2, t2, t);
    r = ui_3d_viewport_get_pixel(9999, 0, 0);
    ok1("626 bad handle rejected", r < 0, r, 0);

    /* ── 630 ui_3d_viewport_set_depth(vp, on) —— 本文件里最硬的一条 ───────────────────── */
    /* -- 630 ui_3d_viewport_set_depth(vp, on) -- the hardest criterion in this file -- */
    ui_3d_viewport_set_time(vp, 0.0f);
    r = ui_3d_viewport_set_depth(vp, 1);
    ok1("630 vp_depth rc", r == 0, r, 0);
    t = ui_3d_viewport_get_pixel(vp, cxs, cys);
    t2 = ui_3d_viewport_get_pixel(vp, cxs, cys);
    ok1("630 REVERSE: same setting twice identical", t == t2, t2, t);
    printf("NOTE 630 center depth-on=%08X\n", rgb(t));
    r = ui_3d_viewport_set_depth(vp, 0);
    c1 = ui_3d_viewport_get_pixel(vp, cxs, cys);
    printf("NOTE 630 center depth-off=%08X\n", rgb(c1));
    ok1("630 depth off->center pixel changed", c1 != t, c1, t);
    ok1("630 depth on->near sphere red", rch(t) > gch(t) + 60 && rch(t) > bch(t) + 60, rch(t), gch(t));
    ok1("630 depth off->far sphere green", gch(c1) > rch(c1) + 60 && gch(c1) > bch(c1) + 60, gch(c1), rch(c1));
    ui_3d_viewport_set_depth(vp, 1);
    c2 = ui_3d_viewport_get_pixel(vp, cxs, cys);
    ok1("630 depth on again->pixel identical", c2 == t, c2, t);
    r = ui_3d_viewport_set_depth(9999, 1);
    ok1("630 bad handle rejected", r < 0, r, 0);

    /* ── 627 ui_3d_viewport_set_clock(vp, mode, permille)(这一条要真实墙钟)─────────── */
    /* -- 627 ui_3d_viewport_set_clock(vp, mode, permille) (this one needs the real wall clock) -- */
    ui_3d_obj_set_spin(o_par, 0.0f, 180.0f, 0.0f);
    ui_3d_obj_set_rot(o_par, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_time(vp, -1.0f);                 /* 交回宿主实时钟 */
                                                        /* Hand the clock back to the host's real-time clock */
    r = ui_3d_viewport_set_clock(vp, 1, 0);                 /* 0 = 暂停 */
                                                            /* 0 = pause */
    ok1("627 vp_clock(pause) rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_kid); a0 = sx(sc);
    ui_msg_fetch_wait(300);
    sc = ui_3d_obj_get_screen(o_kid); a1 = sx(sc);
    ok1("627 paused->300ms later unmoved", a0 == a1, a1, a0);
    r = ui_3d_viewport_set_clock(vp, 0, 8000);              /* 跑,8 倍速(180*8=1440 度/秒)*/
                                                            /* Run at 8x speed (180*8 = 1440 deg/s) */
    ok1("627 vp_clock(run) rc", r == 0, r, 0);
    ui_msg_fetch_wait(600);
    sc = ui_3d_obj_get_screen(o_kid); b0 = sx(sc);
    ok1("627 running->position really moved", b0 != a1, b0, a1);
    ui_3d_viewport_set_clock(vp, 1, 0);
    sc = ui_3d_obj_get_screen(o_kid); c1 = sx(sc);
    ui_msg_fetch_wait(200);
    sc = ui_3d_obj_get_screen(o_kid); c2 = sx(sc);
    ok1("627 paused again->frozen again", c1 == c2, c2, c1);
    r = ui_3d_viewport_set_clock(9999, 1, 0);
    ok1("627 bad handle rejected", r < 0, r, 0);

    /* ── 628 ui_3d_viewport_set_time(vp, ms)(手动拨表;ms<0 = 交回实时)────────────── */
    /* -- 628 ui_3d_viewport_set_time(vp, ms) (set the clock by hand; ms<0 = hand back to real time) -- */
    ui_3d_obj_set_spin(o_par, 0.0f, 180.0f, 0.0f);
    ui_3d_viewport_set_time(vp, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid); a0 = sx(sc);
    ui_msg_fetch_wait(250);                          /* 真实时间流逝 250ms */
                                                     /* 250 ms of real time passes */
    sc = ui_3d_obj_get_screen(o_kid); a1 = sx(sc);
    ok1("628 manual clock->real time does not advance", a0 == a1, a1, a0);
    r = ui_3d_viewport_set_time(vp, 1000.0f);
    ok1("628 vp_time rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_kid); b0 = sx(sc);
    ok1("628 manual set to 1s->180deg happened", b0 != a1 && b0 < cxs && a1 > cxs, b0, a1);
    r = ui_3d_viewport_set_time(vp, -1.0f);
    ok1("628 ms<0 hands back to real clock", r == 0, r, 0);
    r = ui_3d_viewport_set_time(9999, 0.0f);
    ok1("628 bad handle rejected", r < 0, r, 0);
    ui_3d_obj_set_spin(o_par, 0.0f, 0.0f, 0.0f);

    /* ── 629 ui_3d_obj_set_parent(obj, parent) ─────────────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    o_pa = nobj(vp2, m_sph);
    o_ch = nobj(vp2, m_sph);
    ui_3d_obj_set_pos(o_ch, 100.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_ch); a0 = sx(sc);
    r = ui_3d_obj_set_parent(o_ch, o_pa);
    ok1("629 obj_parent rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_ch); a1 = sx(sc);
    ok1("629 parent at origin->child unmoved", iabs(a1 - a0) <= 1, a1, a0);
    ui_3d_obj_set_pos(o_pa, 100.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_ch); b0 = sx(sc);
    ok1("629 move parent->child follows", b0 > a1, b0, a1);
    ui_3d_obj_set_pos(o_pa, 0.0f, 0.0f, 0.0f);
    r = ui_3d_obj_set_parent(o_ch, 0);
    ok1("629 parent=0 detaches rc", r == 0, r, 0);
    ui_3d_obj_set_pos(o_pa, 150.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_ch); b1 = sx(sc);
    ok1("629 detached->parent no longer moves child", iabs(b1 - a1) <= 1, b1, a1);
    ui_3d_obj_set_parent(o_ch, o_pa);
    r = ui_3d_obj_set_parent(o_pa, o_ch);
    ok1("629 cycle rejected", r < 0, r, 0);
    r = ui_3d_obj_set_parent(o_ch, 9999);
    ok1("629 missing parent rejected", r < 0, r, 0);
    r = ui_3d_obj_set_parent(9999, 0);
    ok1("629 bad handle rejected", r < 0, r, 0);
    ui_3d_obj_set_parent(o_ch, 0);
    ui_3d_obj_set_pos(o_pa, 0.0f, 0.0f, 0.0f);

    /* ── 632 ui_3d_viewport_set_orbit(vp, yaw_deg, pitch_deg) ────────────────────────── */
    ui_3d_viewport_set_time(vp, 0.0f);
    r = ui_3d_viewport_set_orbit(vp, 0.0f, 0.0f);
    ok1("632 vp_orbit rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_kid); a0 = sx(sc); a1 = sy(sc);
    ok1("632 orbit(0,0) keeps the camera direction", a0 > cxs, a0, cxs);
    r = ui_3d_viewport_set_orbit(vp, 180.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid); b0 = sx(sc);
    ok1("632 yaw 180deg->mirrored about target", b0 < cxs && b0 != a0, b0, a0);
    ok1("632 target (origin) stays put", iabs(sy(sc) - a1) <= 1, sy(sc), a1);
    ui_3d_viewport_set_orbit(vp, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    ok1("632 orbit restored->projection identical", sx(sc) == a0 && sy(sc) == a1, sx(sc), a0);
    /* 俯仰钳 ±89°:给 180° 与给 89° 必须**逐值相同**(不钳的话两者天差地别) */
    /* Pitch clamped to +-89 deg: giving 180 and giving 89 must be **value-identical** (unclamped the two are wildly different) */
    ui_3d_viewport_set_orbit(vp, 0.0f, 89.0f);
    sc = ui_3d_obj_get_screen(o_kid); c1 = sx(sc); c2 = sy(sc);
    ui_3d_viewport_set_orbit(vp, 0.0f, 180.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    ok1("632 pitch clamped to 89 (180 == 89)", sx(sc) == c1 && sy(sc) == c2, sx(sc), c1);
    ui_3d_viewport_set_orbit(vp, 0.0f, 45.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    ok1("632 pitch 45 differs from 89", sx(sc) != c1 || sy(sc) != c2, sx(sc), c1);
    r = ui_3d_viewport_set_orbit(vp, 0.0f, 0.0f);
    ok1("632 orbit rc after all", r == 0, r, 0);
    r = ui_3d_viewport_set_orbit(9999, 0.0f, 0.0f);
    ok1("632 bad handle rejected", r < 0, r, 0);

    /* ── 631 ui_3d_viewport_fit(vp, radius) —— 放在最后:它会把相机推远,改变一切投影 ── */
    /* -- 631 ui_3d_viewport_fit(vp, radius) -- last, because it pushes the camera out and changes every projection -- */
    ui_3d_viewport_set_time(vp, 0.0f);
    ui_3d_viewport_set_camera(vp, 0.0f, 0.0f, 600.0f, 0.0f, 0.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    f0 = iabs(sx(sc) - cxs);
    /* ⚠ 判据的**方向**不能写死「半径变大 ⇒ 偏移变小」:fit 算出来的是**绝对距离**
     * The criterion's **direction** must not be hard-coded as "bigger radius => smaller offset": what fit computes is an **absolute distance**
     *   `d = radius / sin(half)`,而 half 取「竖直半角」与「水平半角」的**小者**
     *     `d = radius / sin(half)`, and half is the **smaller** of the vertical and horizontal half-angles
     *   —— 窄屏(393x757)half=16.7° ⇒ radius 200 时 d=696 > 起始的 600(推远了);
     *     - a narrow screen (393x757) has half=16.7 deg => radius 200 gives d=696 > the starting 600 (pushed out);
     *      宽一点的屏(411x605)half=21.4° ⇒ d=548 < 600(**反而拉近了**)。
     *          a wider screen (411x605) has half=21.4 deg => d=548 < 600 (**pulled in instead**).
     *   实测:411x605 下写死方向的那一条假红。
     *     Measured: the hard-coded direction falsely reddens at 411x605.
     *   ⇒ 改用**夹逼**:半径 50 一定拉近(d≤100)、半径 400 一定推远(d≥800),
     *     => Use a **sandwich** instead: radius 50 always pulls in (d<=100), radius 400 always pushes out (d>=800),
     *     两头都甩开起始的 600,与宽高比无关。
     *          both ends far from the starting 600, independent of aspect ratio. */
    r = ui_3d_viewport_fit(vp, 50.0f);
    ok1("631 vp_fit rc", r == 0, r, 0);
    sc = ui_3d_obj_get_screen(o_kid); f1 = iabs(sx(sc) - cxs);
    ok1("631 small fit radius pulls camera in->offset grows", f1 > f0, f1, f0);
    r = ui_3d_viewport_fit(vp, 400.0f);
    sc = ui_3d_obj_get_screen(o_kid); f2 = iabs(sx(sc) - cxs);
    ok1("631 large fit radius pushes camera out->offset shrinks", f2 < f0, f2, f0);
    /* 同一条 fit 上「半径加倍 ⇒ 距离加倍 ⇒ 偏移减半」(与宽高比无关的**精确**关系) */
    /* On one fit: "radius doubled => distance doubled => offset halved" (an **exact** relation, independent of aspect ratio) */
    ui_3d_viewport_fit(vp, 200.0f);
    sc = ui_3d_obj_get_screen(o_kid); f1 = iabs(sx(sc) - cxs);
    ui_3d_viewport_fit(vp, 400.0f);
    sc = ui_3d_obj_get_screen(o_kid); f2 = iabs(sx(sc) - cxs);
    ok1("631 radius doubled->offset exactly halved", iabs(f2 * 2 - f1) <= 3, f2, f1);
    ui_3d_viewport_fit(vp, 200.0f);
    sc = ui_3d_obj_get_screen(o_kid);
    ok1("631 fit restored->projection identical", iabs(sx(sc) - cxs) == f1, sx(sc), f1);
    r = ui_3d_viewport_fit(vp, 0.0f);     ok1("631 radius=0 rejected", r < 0, r, 0);
    r = ui_3d_viewport_fit(vp, -5.0f);    ok1("631 radius<0 rejected", r < 0, r, 0);
    r = ui_3d_viewport_fit(9999, 200.0f); ok1("631 bad handle rejected", r < 0, r, 0);

    /* ── 615 的上限:同时存在的物体数 = `VML3D_MAX_OBJECTS`(2026-10-08 由 256 抬到 1024)──
     * -- 615's limit: concurrent objects = `VML3D_MAX_OBJECTS` (raised 256 -> 1024 on 2026-10-08) --
     * ⚠ 放在最后:建满之后**任何** ui_3d_obj_create 都会失败,所以前面的用例必须在它之前跑完。
     *   Placed last: once the cap is hit **any** ui_3d_obj_create fails, so every earlier case must have run before it.
     * ⚠ 探针与期望值都走**头文件常量**(`VML3D_MAX_OBJECTS`,`#include <vml_3d.h>` 就有)而不是字面值 ——
     *   Both the probe and the expected value come from the **header constant** (`VML3D_MAX_OBJECTS`), never a literal -
     *   判据问的是"宿主有没有按**文档写着的那个数**拦"(与 613 那对网格上限同款,见那一段的长注释)。
     *   the criterion asks whether the host enforces **the number written in the docs** (same shape as the 613 mesh caps).
     * ⚠ 量出来的错法:把它改回字面值 256 时,这一条读到 1024 ⇒ **立刻红**(不是"任何数都过"的空判据)。
     *   Measured failure mode: with the literal 256 back in place this reads 1024 => immediately red.
     * ⚠ 先攒一个待会儿要放掉的句柄 —— 它是**第二条腿**(上限管的是"同时存在"而不是"一生建过几个")的料。
     *   Stash one handle first - it feeds the **second leg** (the cap is on concurrency, not a lifetime counter).
     *   它计入 base,所以下面那条自证的期望值跟着 +1。 */
    o_cap = nobj(vp3, m_tiny);
    base = g_nobj;
    k = 0;
    r = 0;
    for (i = 0; i < VML3D_MAX_OBJECTS + 200; i = i + 1) {
        r = ui_3d_obj_create(vp3, m_tiny);
        if (r > 0) { k = k + 1; }
    }
    /* 自证账目:此刻活着的物体 = 主视口 7 个(near/far/side/tri/orb/par/kid)
     * Self-check the book: objects alive right now = 7 on the main viewport (near/far/side/tri/orb/par/kid)
     * + vp2 上父子变换那一对 2 个(o_pa/o_ch)+ 上面那个 o_cap = 10。
     *   + the 2 of the parent-child pair on vp2 (o_pa/o_ch) + the o_cap above = 10.
     * 这一行是**给上面那条判据把关的** —— 账本身错了的话,这里先红,
     *   This line is the **gate for the criterion above** - if the book itself is wrong, this goes red first,
     * 免得把「我数错了」误判成「宿主的上限没生效」。
     *   so a miscount is never mistaken for "the host's cap did not take effect". */
    ok1("615 (setup) live object bookkeeping", base == 10, base, 10);
    ok1("615 MaxObjects=VML3D_MAX_OBJECTS enforced",
        (base + k) == VML3D_MAX_OBJECTS, base + k, VML3D_MAX_OBJECTS);
    ok1("615 over limit returns negative", r < 0, r, 0);
    /* 第二条腿:上限管的是**同时存在**的个数 —— 放掉一个就必须还能再建一个,再建就又被拒。
     * Second leg: the cap is on the **concurrent** count - drop one and exactly one more must fit, then reject again.
     * ⚠ 没有这一条时,`ui_3d_obj_free` **漏还一格**(或上限被写成"一生建过几个"的计数器)
     *   会被上一条静默放过 —— 上一条只数总数。
     *   Without it, a `ui_3d_obj_free` that leaks one slot (or a cap implemented as a lifetime counter)
     *   slips past the criterion above, which only counts the total. */
    r = fobj(o_cap);
    ok1("615 free at cap rc", r == 0, r, 0);
    r = nobj(vp3, m_tiny);
    ok1("615 free at cap -> exactly one more allowed", r > 0, r, 1);
    r = ui_3d_obj_create(vp3, m_tiny);
    ok1("615 refilled -> rejected again", r < 0, r, 0);
    r = ui_3d_viewport_free(vp3);                 /* 连带把这一批物体一起收掉 */
                                                  /* Takes this whole batch of objects down with it */
    ok1("608 vp_free of limit-test viewport rc", r == 0, r, 0);

    /* ══════════════════════════════════════════════════════════════════════
     * 三、收尾:出一张能看的画面(2D HUD 合成在 3D 之上),然后报总账
     * III. Wrap-up: produce one presentable frame (2D HUD composited over 3D), then report the totals
     * ══════════════════════════════════════════════════════════════════════ */
    r = ui_3d_viewport_free(vp2);
    ok1("608 cleanup vp2 rc", r == 0, r, 0);
    ui_3d_viewport_fit(vp, 360.0f);
    ui_3d_obj_set_visible(o_par, 1);            /* 让父子那一对也上场 */
                                                /* Bring the parent-child pair on stage too */
    ui_3d_viewport_set_time(vp, -1.0f);           /* 交回实时钟,动画活起来 */
                                                  /* Hand back to the real clock so the animation comes alive */
    ui_3d_viewport_set_clock(vp, 0, 1000);
    for (i = 0; i < 5; i = i + 1) {
        ui_2d_clear(0);
        ui_2d_text(14, 12, "3D 号段 600-632 逐号验收 g3_all", argb(255, 255, 255), 16, VML_ANCHOR_LEFT);
        ui_2d_text(14, 34, "球=深度/颜色  三角=旋转  父不可见+子=旋转缩放自转  上=公转", argb(170, 170, 170), 11, VML_ANCHOR_LEFT);
        ui_2d_present();
        ui_msg_fetch_wait(150);
    }

    /* ══════════════════════════════════════════════════════════════════════════
     * 636–642:让画面"像那么回事"的七项(2026-10-08)
     * 636 雾 / 637 多光源 / 638 材质高光 —— **本批实现**
     * 639 法线贴图 / 640 天空盒 / 641 透明 / 642 平面阴影 —— 号已定,实现待后续批次
     * ══════════════════════════════════════════════════════════════════════════ */
    ui_3d_viewport_set_time(vp, 0.0f);
    ui_3d_obj_set_color(o_side, argb(255, 0, 0));
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, argb(255, 255, 255), 0.42f);
    sc = ui_3d_obj_get_screen(o_side); px = sx(sc); py = sy(sc);

    /* ── 636 ui_3d_viewport_set_fog(vp, argb, znear, zfar) ───────────────────────── */
    t = ui_3d_viewport_get_pixel(vp, px, py);                    /* 无雾基线 */
    ok1("636 baseline no fog (red)", rch(t) > gch(t) + 60, rch(t), gch(t));
    /* 雾色 = 纯蓝、范围 1..2 ⇒ 物体远得多 ⇒ **完全被雾盖住** ⇒ 该像素必须蓝主导 */
    r = ui_3d_viewport_set_fog(vp, argb(0, 0, 255), 1.0f, 2.0f);
    ok1("636 set_fog rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("636 fog->B dominant", bch(t2) > rch(t2) + 60, bch(t2), rch(t2));
    /* REVERSE:`far <= near` ⇒ 关 —— 必须**逐位**回到基线(与"从没调过"同一条路径) */
    r = ui_3d_viewport_set_fog(vp, argb(0, 0, 255), 2.0f, 2.0f);
    ok1("636 fog(far<=near) rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("636 fog off->identical to baseline", t2 == t, t2, t);
    /* ⚠⚠ **近处的物体不该吃雾** —— 这条是补的,而且它补的是**一个真 bug**:
     *   第一版的公式写成 `(far - fz)`,而 `fz` 是**负的**视空间 z ⇒ 展开成 `(far + 距离)`
     *   ⇒ `f` **恒 > 1** ⇒ clamp 之后**任何开了雾的场景都变成纯雾色**。
     *   上面那条只测了"远处有雾"⇒ **没响**;是 `light_demo.c` 出图时"球整个不见了"暴露的。
     * ⚠ 雾的起止设在**物体之后**(900..1000,实测物体约在 600)⇒ 该像素必须**逐位**回到基线。 */
    /* ⚠ 这里**不能假设物体的距离**:前面 631 段的 `ui_3d_viewport_fit` 会把相机推远
     *   (实测距离已经不在 600 的量级),写死一个"我以为的 far"就会假红 —— 第一版正是如此。
     *   ⇒ 用**远大于任何可信距离**的区间来表示"雾在物体之后"。 */
    r = ui_3d_viewport_set_fog(vp, argb(0, 0, 255), 100000.0f, 200000.0f);
    ok1("636 fog(beyond object) rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("636 near object keeps its colour", t2 == t, t2, t);
    /* 半程:`near = 0`、`far` 极大 ⇒ 雾很薄但**不为 0** ⇒ 蓝通道必须介于"无雾"与"全雾"之间 */
    r = ui_3d_viewport_set_fog(vp, argb(0, 0, 255), 0.0f, 100000.0f);
    ok1("636 fog(thin) rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("636 thin fog->between none and full", bch(c1) > bch(t) && bch(c1) < 255, bch(c1), bch(t));
    /* 不存在的视口句柄必须被拒(不静默当成"设上了") */
    r = ui_3d_viewport_set_fog(9999, argb(0, 0, 255), 1.0f, 2.0f);
    ok1("636 fog bad handle rejected", r < 0, r, 0);
    /* 关掉雾,别影响 637 段(`far <= near` ⇒ 与从没调过同一条路径) */
    ui_3d_viewport_set_fog(vp, 0, 0.0f, 0.0f);

    /* ── 637 ui_3d_viewport_set_light_at(vp, index, dx,dy,dz, argb, ambient) ──────── */
    /* ⚠ 底板换成**暗灰**:纯红球在 0.1 环境光下就已经接近饱和(实测 254/255),
     *   再加一盏只能 +1 ⇒ 那条"更亮"的判据**脆到会被浮点误差翻脸**。
     *   暗底板把余量留出来(实测 54 → 108 量级)。
     * ⚠ A dark base: the pure red ball already saturates under ambient 0.1 (measured 254/255),
     *   so "brighter" would only be +1 — too fragile. */
    ui_3d_obj_set_color(o_side, argb(90, 90, 90));
    /* 主光改成**从 +Z 正对相机**(球的正对点被照亮;也便于 638 的高光落在球心) */
    ui_3d_viewport_set_light(vp, 0.0f, 0.0f, 1.0f, argb(255, 255, 255), 0.1f);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("637 baseline 1 light", lum(t) > 60, lum(t), 0);
    /* 第 2 盏**从 -Z**(背对相机)—— 双面光照是 `abs(dot)` ⇒ 它**也**贡献 ⇒ 更亮 */
    r = ui_3d_viewport_set_light_at(vp, 1, 0.0f, 0.0f, -1.0f, argb(255, 255, 255), 0.1f);
    ok1("637 light_at(1) rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("637 2nd light->brighter", lum(t2) > lum(t), lum(t2), lum(t));
    /* REVERSE:`argb = 0` **关掉第 1 盏** ⇒ 主光那段仍在、额外光集合为空 ⇒ **逐位**回到基线 */
    r = ui_3d_viewport_set_light_at(vp, 1, 0.0f, 0.0f, -1.0f, 0, 0.1f);
    ok1("637 light_at(1,argb=0) rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("637 2nd light off->identical", t2 == t, t2, t);
    /* 越界(上限 `VML3D_MAX_LIGHTS` 盏)必须拒。
     * ⚠ 探针值走**头文件常量**而不是字面值 4(2026-10-08 把上限从 4 抬到 8 时,字面值会让这条
     *   **静默变成"测的是合法索引"** —— 它会返回 0 而判据要求 < 0 ⇒ 红是红了,但那是"判据过期",
     *   不是"实现坏了"。走常量之后,判据问的永远是"宿主有没有按**文档写着的那个数**拦"。 */
    r = ui_3d_viewport_set_light_at(vp, VML3D_MAX_LIGHTS, 0.0f, 0.0f, 1.0f, argb(255, 255, 255), 0.0f);
    ok1("637 index VML3D_MAX_LIGHTS rejected", r < 0, r, 0);
    r = ui_3d_viewport_set_light_at(vp, 0 - 1, 0.0f, 0.0f, 1.0f, argb(255, 255, 255), 0.0f);
    ok1("637 index -1 rejected", r < 0, r, 0);
    /* `#612` 与 `light_at(vp, 0, …)` 是**同一条路**(宿主共用 ApplyLight)⇒ 画面逐位相同 */
    ui_3d_viewport_set_light(vp, 0.0f, 0.0f, 1.0f, argb(255, 255, 255), 0.1f);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ui_3d_viewport_set_light_at(vp, 0, 0.0f, 0.0f, 1.0f, argb(255, 255, 255), 0.1f);
    c2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("637 light == light_at(0)", c1 == c2, c1, c2);

    /* ── 638 ui_3d_obj_set_material(obj, specular_argb, shininess) ───────────────── */
    /* ⚠ 高光斑落在**球心那个像素**:主光与视线都是 +Z ⇒ `H = normalize(Z+Z) = +Z`
     *   ⇒ 球上"正对相机"那点的法线正好是 H。那里 `dot(n,H) = 1` ⇒ **亮度与指数无关**;
     *   指数只改变**斑的大小** ⇒ 判据必须数"亮点个数"而不是读中心一个点。 */
    ui_3d_obj_set_color(o_side, argb(60, 60, 60));               /* 暗底,高光才看得出来 */
    t = ui_3d_viewport_get_pixel(vp, px, py);                    /* 无高光基线 */
    r = ui_3d_obj_set_material(o_side, argb(255, 255, 255), 8.0f);
    ok1("638 set_material rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("638 specular->brighter at centre", lum(t2) > lum(t) + 60, lum(t2), lum(t));
    n = count_lum_over(vp, px, py, 18, 200);                     /* 指数 8:斑大,"亮点"多 */
    ui_3d_obj_set_material(o_side, argb(255, 255, 255), 200.0f);
    k = count_lum_over(vp, px, py, 18, 200);                     /* 指数 200:斑小,"亮点"少 */
    ok1("638 shininess 8->200 concentrates the highlight", k < n, k, n);
    /* REVERSE:`specular = 0` ⇒ 逐位回到"没有高光"那一幅 */
    r = ui_3d_obj_set_material(o_side, 0, 8.0f);
    ok1("638 material off rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("638 specular=0->identical to baseline", c1 == t, c1, t);
    r = ui_3d_obj_set_material(9999, argb(255, 255, 255), 8.0f);
    ok1("638 bad handle rejected", r < 0, r, 0);

    /* ── 639 ui_3d_obj_set_normal_map(obj, image) ────────────────────────────────── */
    /* 判据用**两张已知方向的法线图**:
     *   · 中性图 `(128,128,255)` = 切线空间的 (0,0,1) ⇒ **数学上恒等** ⇒ 画面必须几乎不变
     *     (⚠ 用**阈值**而不是"逐位":屏幕空间导数推出来的 TBN 有浮点误差,实测最大差 ≤ 2)
     *   · 朝 +X 的图 `(255,128,128)` ⇒ 法线整体偏转 ⇒ 画面**必须变**
     * ⚠ 先把底板改成中灰:暗底上看不出"变没变"。 */
    ui_3d_obj_set_color(o_side, argb(120, 120, 120));
    ui_3d_viewport_set_light(vp, 0.0f, 0.0f, 1.0f, argb(255, 255, 255), 0.35f);
    ui_3d_viewport_set_fog(vp, 0, 0.0f, 0.0f);

    /* ⚠ 这一段**必须用一个带 uv 的网格**(见 mb_vertex_uv 的说明)—— `o_side` 的 uv 全是 0,
     *   法线贴图在它上面会被整个跳过,判据"全绿"而实现是坏的。现建一个正方片。 */
    ui_3d_obj_set_visible(o_side, 0);
    mb_vertex_uv(0, 0 - 1.0f, 0 - 1.0f, 0.0f, 0.0f, 0.0f);
    mb_vertex_uv(1, 1.0f, 0 - 1.0f, 0.0f, 1.0f, 0.0f);
    mb_vertex_uv(2, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f);
    mb_vertex_uv(3, 0 - 1.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;
    m_uvq = nmesh(mb_ptr(), 4, 6);
    o_uvq = nobj(vp, m_uvq);
    ui_3d_obj_set_color(o_uvq, argb(120, 120, 120));
    /* ⚠ **挪开**:放在世界原点会与既有那批物体(球/三角/环…)**重叠**,深度测试下
     *   读到的是**别人**的像素(实测:读到纯红的球,法线贴图那条判据就永远"没变化")。 */
    ui_3d_obj_set_pos(o_uvq, 0.0f, 220.0f, 0.0f);
    sc = ui_3d_obj_get_screen(o_uvq); px = sx(sc); py = sy(sc);   /* ⚠ 换成"方片"那个点 */
    ok1("639 quad created", m_uvq > 0 && o_uvq > 0, m_uvq, o_uvq);

    t = ui_3d_viewport_get_pixel(vp, px, py);                    /* 无法线图基线 */

    ui_2d_rect(4, 4, 16, 16, argb(128, 128, 255), 1, 0, 0);     /* 中性法线图 */
    ui_2d_present();
    img_a = ui_2d_get_image(4, 4, 16, 16);
    r = ui_3d_obj_set_normal_map(o_uvq, img_a);
    ok1("639 normal_map rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("639 neutral normal map->essentially unchanged",
        iabs(rch(t2) - rch(t)) <= 2 && iabs(gch(t2) - gch(t)) <= 2 && iabs(bch(t2) - bch(t)) <= 2,
        rch(t2), rch(t));

    ui_2d_rect(4, 4, 16, 16, argb(255, 128, 128), 1, 0, 0);     /* 朝 +X 的法线 */
    ui_2d_present();
    img_b = ui_2d_get_image(4, 4, 16, 16);
    r = ui_3d_obj_set_normal_map(o_uvq, img_b);
    ok1("639 tilted normal map rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("639 tilted normal map->image changed", c1 != t2, c1, t2);

    /* REVERSE:传 0 取消 ⇒ 必须**逐位**回到基线("没贴"与"贴上又摘掉"同一条路径) */
    r = ui_3d_obj_set_normal_map(o_uvq, 0);
    ok1("639 unset normal map rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("639 unset->identical to baseline", c1 == t, c1, t);
    /* 收尾:把这个临时方片收掉,恢复 o_side(后面的判据用它) */
    ui_3d_obj_free(o_uvq); g_nobj = g_nobj - 1;
    ui_3d_mesh_free(m_uvq); g_nmesh = g_nmesh - 1;
    ui_3d_obj_set_visible(o_side, 1);
    sc = ui_3d_obj_get_screen(o_side); px = sx(sc); py = sy(sc);

    /* ── 640 ui_3d_viewport_set_skybox(vp, image) ────────────────────────────────── */
    /* 用一张**左右四色**的等距圆柱图:相机 yaw 不变 ⇒ 屏幕中心看到的必须是"正前方"那一格。
     * ⚠ 这一条能精确定位"映射错在第几格"——纯色图退化时也会绿,所以**必须多色**。 */
    ui_2d_rect(4, 4, 8, 8, argb(255, 0, 0), 1, 0, 0);           /* 0° 方向:红 */
    ui_2d_rect(12, 4, 8, 8, argb(0, 255, 0), 1, 0, 0);
    ui_2d_rect(20, 4, 8, 8, argb(0, 0, 255), 1, 0, 0);
    ui_2d_rect(28, 4, 8, 8, argb(255, 255, 0), 1, 0, 0);
    ui_2d_present();
    img_a = ui_2d_get_image(4, 4, 32, 8);
    ok1("640 skybox image -> handle", img_a > 0, img_a, 0);
    t = ui_3d_viewport_get_pixel(vp, 4, 4);                      /* 角落:基线是清屏/雾色 */
    r = ui_3d_viewport_set_skybox(vp, img_a);
    ok1("640 set_skybox rc", r == 0, r, 0);
    t2 = ui_3d_viewport_get_pixel(vp, 4, 4);
    ok1("640 skybox->corner no longer background", t2 != t, t2, t);
    /* REVERSE:取消 ⇒ 角落必须**逐位**回到基线 */
    r = ui_3d_viewport_set_skybox(vp, 0);
    ok1("640 clear skybox rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp, 4, 4);
    ok1("640 clear->identical to baseline", c1 == t, c1, t);

    /* ── 641 ui_3d_obj_set_blend(obj, on) —— 透明 ────────────────────────────────── */
    /* 两块**Z 上错开**的透明板:不排序(按创建顺序画)时,"先红后蓝"与"先蓝后红"结果**不同**;
     * 排了序(远→近)之后两者**必须逐位相同** —— 这就是本节最有力的那条判据。
     * ⚠ 必须同时断言"该像素不是背景",否则"两次都空"也会假绿。 */
    ui_3d_obj_set_visible(o_side, 0);
    mb_vertex_uv(0, 0 - 60.0f, 0 - 60.0f, 0.0f, 0.0f, 0.0f);
    mb_vertex_uv(1, 60.0f, 0 - 60.0f, 0.0f, 1.0f, 0.0f);
    mb_vertex_uv(2, 60.0f, 60.0f, 0.0f, 1.0f, 1.0f);
    mb_vertex_uv(3, 0 - 60.0f, 60.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;
    m_uvq = nmesh(mb_ptr(), 4, 6);
    ok1("641 quad mesh rc", m_uvq > 0, m_uvq, 0);

    /* 第一次:先建"远"的红板、后建"近"的蓝板 */
    o_tmp = nobj(vp, m_uvq);                       /* 远:z = -40(离相机更远) */
    ui_3d_obj_set_pos(o_tmp, 0.0f, 0.0f, 0 - 40.0f);
    ui_3d_obj_set_color(o_tmp, argb(255, 0, 0));
    ui_3d_obj_set_blend(o_tmp, 1);
    o_tmpa = nobj(vp, m_uvq);                      /* 近:z = +40 */
    ui_3d_obj_set_pos(o_tmpa, 0.0f, 0.0f, 40.0f);
    ui_3d_obj_set_color(o_tmpa, argba(128, 0, 0, 255));
    ui_3d_obj_set_blend(o_tmpa, 1);
    ok1("641 two blended objects rc", o_tmp > 0 && o_tmpa > 0, o_tmp, o_tmpa);
    sc = ui_3d_obj_get_screen(o_tmpa); px = sx(sc); py = sy(sc);
    t = ui_3d_viewport_get_pixel(vp, px, py);
    /* ⚠ 只断言"**确实画了东西**"(不是背景)—— 这个场景很密,硬要求"既不是纯红也不是纯蓝"
     *   会被**别的物体**的像素搅掉(实测读到纯红:那是别人,不是这两块板)。
     *   真正分辨"排没排序"的是下面那条"两次顺序结果相同"。 */
    ok1("641 blended pixel drawn (not bg)", !is_bg(t), t, 0);

    /* 第二次:**反着建**(先蓝后红)⇒ 同一点必须**逐位相同**(排序把它归一了) */
    ui_3d_obj_free(o_tmpa); g_nobj = g_nobj - 1;
    ui_3d_obj_free(o_tmp); g_nobj = g_nobj - 1;
    o_tmpb = nobj(vp, m_uvq);                      /* 先建近的蓝 */
    ui_3d_obj_set_pos(o_tmpb, 0.0f, 0.0f, 40.0f);
    ui_3d_obj_set_color(o_tmpb, argba(128, 0, 0, 255));
    ui_3d_obj_set_blend(o_tmpb, 1);
    o_tmpa = nobj(vp, m_uvq);                      /* 后建远的红 */
    ui_3d_obj_set_pos(o_tmpa, 0.0f, 0.0f, 0 - 40.0f);
    ui_3d_obj_set_color(o_tmpa, argb(255, 0, 0));
    ui_3d_obj_set_blend(o_tmpa, 1);
    t2 = ui_3d_viewport_get_pixel(vp, px, py);
    ok1("641 order-independent (sorted back-to-front)", t2 == t, t2, t);

    /* REVERSE:关掉透明 ⇒ 回到"不透明、按创建顺序"的行为 */
    ui_3d_obj_set_blend(o_tmpb, 0);
    ui_3d_obj_set_blend(o_tmpa, 0);
    ui_3d_obj_free(o_tmpb); g_nobj = g_nobj - 1;
    ui_3d_obj_free(o_tmpa); g_nobj = g_nobj - 1;
    ui_3d_mesh_free(m_uvq); g_nmesh = g_nmesh - 1;

    /* ── 642 ui_3d_viewport_set_shadow(vp, on) —— 平面投影阴影(接收面固定 y = 0)────── */
    /* 判据三条:① 开了之后画面**变**(多了一块暗区)② 关掉 ⇒ **逐位**回基线
     *   ③ 换光方向 ⇒ 影子的位置跟着变(证明它真的按光方向投影,不是随便画一块) */
    ui_3d_obj_set_visible(o_side, 1);
    /* ⚠⚠ **用一个独立视口**做这条判据:g3_all 的主场景很密(十几个物体叠在原点附近),
     *   而"影子落在哪儿"是**算出来**的(物体沿光方向压到 y=0)—— 它正好落在别的物体身上,
     *   读数反复被顶掉(实测三次都是背景/别人)。新视口里只有**一个球 + 一个影子**,干净。
     * ⚠ 相机(0,300,600)看原点、光竖直向下 ⇒ 球在 (0,100,0) 时**影子落在世界原点**,
     *   而原点投影在画面中心 ⇒ 读中心那个像素即可。 */
    vp2 = ui_3d_viewport_create(0, 0, 256, 256, 0);
    ui_3d_viewport_set_camera(vp2, 0.0f, 300.0f, 600.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp2, 60.0f, 1.0f, 8000.0f);
    ui_3d_viewport_set_bg(vp2, argb(15, 15, 15));
    ui_3d_viewport_set_light(vp2, 0.0f, 1.0f, 0.0f, argb(255, 255, 255), 0.5f);
    o_tmp = nobj(vp2, m_sph);
    ui_3d_obj_set_pos(o_tmp, 0.0f, 100.0f, 0.0f);
    ui_3d_obj_set_scale(o_tmp, 20.0f);
    ui_3d_obj_set_color(o_tmp, argb(200, 200, 200));
    ok1("642 dedicated viewport obj", vp2 > 0 && o_tmp > 0, vp2, o_tmp);

    a0 = ui_3d_viewport_get_pixel(vp2, 128, 128);          /* 关影子:中心是地面(背景) */
    ok1("642 centre is ground before shadow", is_bg(a0), a0, 0);
    r = ui_3d_viewport_set_shadow(vp2, 1);
    ok1("642 set_shadow rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp2, 128, 128);          /* 开影子:中心被影子压暗 */
    ok1("642 shadow darkens the ground under the object",
        lum(c1) < lum(a0) || c1 != a0, c1, a0);
    r = ui_3d_viewport_set_shadow(vp2, 0);
    ok1("642 clear shadow rc", r == 0, r, 0);
    c1 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("642 shadow off->identical to baseline", c1 == a0, c1, a0);
    ui_3d_obj_free(o_tmp); g_nobj = g_nobj - 1;
    ui_3d_viewport_free(vp2);

    /* ── 656–660 骨骼蒙皮(3D 渲染能力第四批)────────────────────────────────────────
     * ── 656-660 skinning (3D rendering capability, fourth batch)
     *
     * 判据一:**同一块方片**分别用 32 字节与 56 字节两种布局建出来,同一个视口、同一个相机、
     *   同一个颜色 ⇒ **读同一个像素必须逐位相同**(后端按错步长读 ⇒ 方片挪位 ⇒ 像素不同,
     *   而**不报任何错**)。另配**阳性对照**:老布局那一次读到的必须是"有东西"(不是背景)——
     *   否则"两次都读到背景"会让它恒真(本仓 E142 那条"缺席判据在空输出上恒真")。
     * 判据二(**蒙皮真的生效**):把骨头**挪走** ⇒ 画面必须变(中心那一块必须空出来);
     *   把骨头挪**回来** ⇒ 必须逐位回到原样。这是"网格跟着变形"的像素级证据。
     *
     * ⚠⚠ **对照物必须先藏起来再读蒙皮那一份**(本仓 E142 的同族坑,2026-10-08 修):
     *   两块方片**同位置、同深度**,而深度测试是 `GL_LESS` ⇒ **后画的那个被判失败、直接丢掉**
     *   ⇒ 中心那个像素**永远来自老布局那一块**,判据一测的东西根本不是蒙皮网格
     *   (它"全绿"而什么也没验)。⇒ 先把 `o_tmp` 设成不可见,再读 `o_sk`。
     *   ⚠ 这一条同时说明:**"两次读数相同"这类判据必须问一句"这两次真的在看同一个东西吗"**。 */
    /* Criterion 1: the same quad in both layouts must give a bit-identical pixel (a wrong stride moves it).
     * Criterion 2 (skinning actually works): move the bone away => the picture must change (the centre must
     *   empty out); move it back => bit-identical to the original.
     * ⚠⚠ The control must be **hidden** before reading the skinned one: the two quads sit at the same place
     *   and depth, and GL_LESS discards the later one, so the centre pixel would ALWAYS come from the old
     *   layout - the criterion would be green while testing nothing. */
    vp2 = ui_3d_viewport_create(0, 0, 256, 256, 0);
    ui_3d_viewport_set_camera(vp2, 0.0f, 300.0f, 600.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp2, 60.0f, 1.0f, 8000.0f);
    ui_3d_viewport_set_bg(vp2, argb(15, 15, 15));
    ui_3d_viewport_set_light(vp2, 0.0f, 1.0f, 0.0f, argb(255, 255, 255), 0.5f);
    ok1("656 dedicated viewport", vp2 > 0, vp2, 0);

    /* ① 对照:老布局(32 字节)的方片 —— 屏幕中心(= 注视点 = 方片中心)必须有东西 */
    /* 1) Control: the old 32-byte quad - the screen centre (= the look-at point) must show something. */
    gen_quad_old(200.0f);
    m_tmp = nmesh(mb_ptr(), g_vn, g_in);
    o_tmp = nobj(vp2, m_tmp);
    ui_3d_obj_set_color(o_tmp, argb(0, 200, 0));
    ok1("656 control mesh/obj built (old 32B layout)", m_tmp > 0 && o_tmp > 0, m_tmp, o_tmp);
    c1 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("656 control centre is covered (positive control)", !is_bg(c1), c1, 0);
    /* ⚠⚠ 把对照**藏起来** —— 见本组开头那段说明(同位置同深度时后画的会被深度测试丢掉) */
    /* ⚠⚠ Hide the control - see the note at the top (same position/depth: the later draw is depth-rejected). */
    ui_3d_obj_set_visible(o_tmp, 0);

    /* ② 同一块方片用**56 字节**布局再建一份 ⇒ 同一个像素必须**逐位相同** */
    /* 2) The same quad in the **56-byte** layout - the same pixel must be **bit-identical**. */
    gen_quad_sk(200.0f, 1.0f, 0.0f, 0.0f, 0.0f);
    m_sk = ui_3d_mesh_create_skinned(sk_ptr(), 4, 6);
    if (m_sk > 0) g_nmesh = g_nmesh + 1;
    o_sk = nobj(vp2, m_sk);
    ui_3d_obj_set_color(o_sk, argb(0, 200, 0));
    ok1("656 skinned mesh/obj built (56B layout)", m_sk > 0 && o_sk > 0, m_sk, o_sk);
    c2 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("656 skinned layout draws the identical pixel", c2 == c1, c2, c1);

    /* ③ 骨架:建(1 根骨)/ 越界报错 / 逐帧写 / 绑到**带骨骼**的网格上 */
    /* 3) Skeleton: create (1 bone) / out-of-range is an error / per-frame write / bind to a skinned mesh. */
    SKM[0] = 1.0f;  SKM[5] = 1.0f;  SKM[10] = 1.0f; SKM[15] = 1.0f;   /* 单位阵 */
    sk1 = ui_3d_skeleton_create((int)&SKM[0], 1);
    ok1("657 skeleton_create(1 bone) rc", sk1 > 0, sk1, 0);
    r = ui_3d_skeleton_create((int)&SKM[0], 0);
    ok1("657 bone_count=0 rejected", r < 0, r, 0);
    r = ui_3d_skeleton_create((int)&SKM[0], 49);
    ok1("657 bone_count=49 rejected (cap 48)", r < 0, r, 0);
    r = ui_3d_skeleton_set(sk1, (int)&SKM[0], 1);
    ok1("658 skeleton_set rc", r == 0, r, 0);
    r = ui_3d_skeleton_set(sk1, (int)&SKM[0], 2);
    ok1("658 count > capacity rejected", r < 0, r, 0);
    r = ui_3d_obj_set_skeleton(o_sk, sk1);
    ok1("660 bind skeleton to skinned mesh rc", r == 0, r, 0);
    /* 反证:普通网格上绑骨架必须被**拒**(它没有 joints/weights 那两组属性) */
    /* Reverse control: binding a skeleton to a **plain** mesh must be rejected (it has no joints/weights). */
    r = ui_3d_obj_set_skeleton(o_tmp, sk1);
    ok1("660 bind to plain mesh rejected", r < 0, r, 0);

    /* ④ 绑上单位骨架之后画面**还是同一个像素**(单位阵是蒙皮公式的恒等元素) */
    /* 4) With an identity skeleton bound the picture is **still that same pixel** (identity is the identity element of the skinning formula). */
    c2 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("660 identity skeleton keeps the picture", c2 == c1, c2, c1);

    /* ⑤ **蒙皮真的生效**:把骨头挪走(沿 x 平移 1000)⇒ 方片必须跟着离开视野 */
    /* 5) Skinning really works: move the bone away (translate x by 1000) => the quad must leave the view. */
    SKM[12] = 1000.0f;                                  /* 列主序:平移在 [12] */
    r = ui_3d_skeleton_set(sk1, (int)&SKM[0], 1);
    ok1("658 skeleton_set (bone moved away) rc", r == 0, r, 0);
    c2 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("656/658 bone moved => the quad follows (centre is now background)",
        is_bg(c2), c2, c1);

    /* ⑥ 把骨头挪**回来** ⇒ 必须逐位回到原样(这条同时是"上一条不是碰巧变空"的反证) */
    /* 6) Move the bone **back** => must be bit-identical again (this also rules out "it just went blank by luck"). */
    SKM[12] = 0.0f;
    r = ui_3d_skeleton_set(sk1, (int)&SKM[0], 1);
    ok1("658 skeleton_set (bone back) rc", r == 0, r, 0);
    c2 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("656/658 bone back => bit-identical to the original", c2 == c1, c2, c1);

    /* ⑦ 释放骨架 ⇒ 物体退回绑定姿势(画面**不变**、不崩),重复释放报错 */
    /* 7) Freeing the skeleton puts the object back to the bind pose (picture unchanged, no crash); double free is an error. */
    r = ui_3d_skeleton_free(sk1);
    ok1("659 skeleton_free rc", r == 0, r, 0);
    c2 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("659 after free: bind pose, picture unchanged", c2 == c1, c2, c1);
    r = ui_3d_skeleton_free(sk1);
    ok1("659 double free rejected", r < 0, r, 0);

    /* ⑧ 老布局那一块**一直没被碰过**:把它放出来,画面必须仍然是 c1(蒙皮的改动不波及老网格) */
    /* 8) The old-layout quad was never touched: show it again and the picture must still be c1. */
    ui_3d_obj_set_visible(o_tmp, 1);
    c2 = ui_3d_viewport_get_pixel(vp2, 128, 128);
    ok1("656 old 32-byte mesh is unaffected (same pixel, same as before skinning)", c2 == c1, c2, c1);
    ui_3d_obj_set_visible(o_tmp, 0);

    ui_3d_obj_set_skeleton(o_sk, 0);
    fobj(o_sk);
    fobj(o_tmp);
    ui_3d_mesh_free(m_sk); g_nmesh = g_nmesh - 1;
    ui_3d_mesh_free(m_tmp); g_nmesh = g_nmesh - 1;
    ui_3d_viewport_free(vp2);

    /* 收尾:把 o_side 恢复成后面判据期待的"蓝" */
    ui_3d_obj_set_color(o_side, argb(0, 0, 255));

    /* ⚠ NUMS = **真实覆盖到的号数**,加一批就要跟着加 —— 它不是"清单里有多少号"
     *   (那是宿主常量 `Vml3d.AllNumbers` 的长度),而是**这个程序真的碰到了几个号**。
     *   ⚠ NUMS counts the numbers this program actually exercises, not the host's number list. */
    printf("OK=%d FAIL=%d UNIMPL=%d NUMS=%d\n", g_ok, g_fail, g_unimpl, 45);
    printf("DONE=1\n");
    return 0;
}

/* ══════════════════════════════════════════════════════════════════════════
 * 实测记录(2026-10-01,macOS / vmlcli 无头 / --screen 393x757)
 * Measured record (2026-10-01, macOS / headless vmlcli / --screen 393x757)
 *
 * ## ① 漏 `#include <math.h>` ⇒ 球体一个像素都不画,而程序不报错
 * ## 1) Missing `#include <math.h>` => the sphere drew zero pixels, and the program reported no error
 * 症状:`gen_sphere` 的顶点全部退化,`ui_3d_obj_get_screen` 报的坐标**是对的**(那是矩阵算的),
 * Symptom: every `gen_sphere` vertex degenerated, while the coordinates `ui_3d_obj_get_screen` reported were **correct** (the matrix computed them),
 *       但那颗球在屏幕上**不存在** —— 读它的中心像素得到的是背景色。
 *              yet the ball **did not exist** on screen - reading its centre pixel gave the background colour.
 * 根因(量出来的):`sin(0.5f)` 存进 float 数组变成 **0.5**、`vcos(0) = sin(0+π/2)` 变成
 * Root cause (measured): `sin(0.5f)` stored into a float array became **0.5**, `vcos(0) = sin(0+pi/2)` became
 *       **1.5707963**(= vcos 里那个字面量)⇒ 顶点坐标全是常数 ⇒ 退化三角形。
 *             **1.5707963** (= the literal inside vcos) => vertex coordinates all constant => degenerate triangles.
 * 判据:加了 include 之后逐字节对上(`MB[0..7]` = (0,1,0, 0,1,0, 0,0)、
 * Criterion: after adding the include everything matched byte for byte (`MB[0..7]` = (0,1,0, 0,1,0, 0,0),
 *       `MB[8..15]` 同、索引 = 0,11,1,…),球体投影半径实测 **66 px**,
 *             `MB[8..15]` the same, indices = 0,11,1,...), and the sphere's projected radius measured **66 px**,
 *       与 `(sh/2)/tan(30°) × 60/600 = 65.6` 相符。
 *             matching `(sh/2)/tan(30 deg) x 60/600 = 65.6`.
 *
 * ## ② 颜色常量别手算负数(把我自己坑了两轮)
 * ## 2) Do not hand-compute negative colour constants (it cost me two rounds)
 * `-16777216` 是 `0xFF000000`(黑),红是 `-65536`(`0xFFFF0000`)。第一版把前者当红,
 * `-16777216` is `0xFF000000` (black) and red is `-65536` (`0xFFFF0000`). The first version took the former for red,
 * 于是断言追着一个「本来就该是黑」的像素跑,还差点报一个并不存在的宿主 bug。
 * so an assertion chased a pixel that "was supposed to be black" and nearly reported a host bug that did not exist.
 * ⇒ 本文件用 `argb(r,g,b)` 凑,并在开头打一行 `COLOR ...` 自证。
 * => This file uses `argb(r,g,b)` and prints a `COLOR ...` line up front to self-check.
 * 顺带核实了着色模型:`|N·L|` 双面、`ambient + (1-ambient)·d`,
 * While at it, the shading model was verified: two-sided `|N.L|`, `ambient + (1-ambient)*d`,
 * 正对相机的面 d≈0.311、环境光 0.42 ⇒ 系数 0.60(红球中心读回 `FF980000`)。
 * the face facing the camera has d~0.311 and ambient 0.42 => factor 0.60 (the red ball's centre reads back `FF980000`).
 *
 * ## ③ 深度判据的画序(`docs/VML3D接口.md` §十)
 * ## 3) The draw order for the depth criterion (3D interface doc, section 10)
 * 反证要能响,必须**近的先建、远的后建**:关掉深度测试后,后画的远的才盖得上来。
 * For the reverse test to fire, the **near one must be created first and the far one after**: only then does turning the depth test off let the later-drawn far ball cover it.
 * 实测中心像素:开 = `FF980000`(红,近球)、关 = `FF009800`(绿,远球)。
 * Measured centre pixel: on = `FF980000` (red, the near ball), off = `FF009800` (green, the far ball).
 * 画序反过来写,关掉深度测试结果不变 —— 那条判据就永远响不了。
 * Written the other way round, turning the depth test off changes nothing - and that criterion can never fire.
 *
 * ## ④ `ui_3d_viewport_get_pixel` 的整帧缓存不是近似
 * ## 4) `ui_3d_viewport_get_pixel`'s whole-frame cache is not an approximation
 * 宿主在"真重画过"那一拍才把缓存置脏,没脏就照用 ⇒ 语义与逐点读等价。
 * The host marks the cache dirty only on the frame it really redrew, and reuses it when clean => semantically equivalent to a per-pixel read.
 * 本文件的「同一设置连读两次必须相同」那条反证,同时把这条缓存语义钉住了。
 *   This file's "same setting read twice must be identical" reverse test also pins down that cache semantics.
 */