← 都来码 / Dolaima← 例程索引c/app/weather.c原文

c/app/weather.c · 28734 字节 · 下载原文

/* weather.c —— 常用小程序②:**天气**(联网抓一周预报 → 存文件 → 彩色显示)
 * weather.c -- Everyday utility #2: **weather** (fetch a one-week forecast, save it, then show it)
 *
 * 四段:① 裸套接字取回 api.open-meteo.com 的预报(明文 HTTP,80 端口 ——
 * Four stages: (1) fetch the forecast from api.open-meteo.com over a raw socket (plain HTTP on port 80 —
 * VM 里没有 TLS,所以不能走 https);② 剥掉响应头,把正文写进 weather.txt
 * there is no TLS in the VM, so https is impossible); (2) strip the response headers and write the body into weather.txt
 * (**相对路径**:手机上沙箱根 = 工作区,绝对路径会被拒);③ 解析 CSV(**要 `format=csv`**,
 * (**relative path**: on the phone the sandbox root is the workspace, absolute paths are rejected); (3) parse the CSV (**ask for `format=csv`**,
 * 比 JSON 少一整层解析,三门语言移植时都省事);④ 用 `tty_*` 画一张彩色的周预报。
 * which saves a whole parsing layer over JSON and makes porting to other languages easier); (4) draw a colour week card with `tty_*`.
 *
 * ⚠ **抓不到就回落读本地**(用户 2026-10-07 定:「真数据 + 离线回落」):DNS / 连接 / HTTP 任一失败,
 * ⚠ **Falls back to the local file when the fetch fails** (user's call, 2026-10-07: "real data plus an offline fallback"):
 *   就去读上一次留下的 `weather.txt` 并照常显示,只在标题上标一行"(离线:读本地缓存)"。
 *   when DNS, the connection or HTTP itself fails, it reads the `weather.txt` left by the last run and displays that,
 *   于是**没网的机器上这个例程照样有东西看**,而"联网怎么写"那部分一行没少。
 *   marking the title "(offline: local cache)" — so the sample still shows something on a machine without a network,
 *   while the "how to do networking" part is unchanged.
 *
 * 跑法(桌面脚手架,秒级,别为改一行去打 APK):
 * How to run (desktop scaffolding, seconds — do not build an APK just to change one line):
 *     dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll Examples/c/app/weather.c --vml-home third_party/vml --timeout 60
 * 手机上:命令行页敲 `vml run examples/c/app/weather.c`,文件落在工作区根的 weather.txt。
 * On the phone: type `vml run examples/c/app/weather.c` in the command-line page; the file lands in weather.txt at the workspace root.
 *
 * ⚠ 三个已实测的坑,改动本文件前先读:
 * ⚠ Three pitfalls already confirmed by testing — read these before changing this file:
 *
 * ① **域名不由宿主解析**(**2026-10-04 复验仍成立**:宿主源码 `VMLRuntime/VMLRuntime.Syscall.OS.cs` 里
 * ① **The host does not resolve domain names** (**still holds, re-checked 2026-10-04**: in the host source `VMLRuntime/VMLRuntime.Syscall.OS.cs`
 *    `#334 SocketConnect(fd, ip, port)` 的实现是 `IPAddress.Parse(ip)`),喂域名会直接抛异常返回 -1。
 *    `#334 SocketConnect(fd, ip, port)` is implemented as `IPAddress.Parse(ip)`), so feeding it a domain name throws and returns -1.
 *    所以必须先 `#338 DnsResolve(host)` 把域名换成 IP 字符串。
 *    The host must therefore be turned into an IP string first with `#338 DnsResolve(host)`.
 *
 * ② **别写「先 `asm("SYSCALL #N")`、再 `return 某个局部量`」这种取值法**(**2026-10-04 复验仍成立**)。
 * ② **Do not write "`asm("SYSCALL #N")` then `return some local`" as the way to get the result** (**still holds, re-checked 2026-10-04**).
 *    原理:C 前端对**带 `#` 的 SYSCALL** 会额外补一条 `move [R12-4], R0`,把结果塞进一个**硬编码槽位**;
 *    Why: the C front end appends an extra `move [R12-4], R0` after a **SYSCALL carrying a `#`**, storing the result in a **hardcoded slot**,
 *    函数的局部量却在 R12-8 —— 两者不是一个格子,于是读到的是**未初始化的栈内容**。
 *    while the function's locals live at R12-8 — not the same cell, so you read **uninitialized stack contents**.
 *    实测(2026-10-04):`int badfopen(...){ int r; asm("SYSCALL #110, ${path}, ${mode}"); return r; }` 取回 **0**,
 *    Measured (2026-10-04): `int badfopen(...){ int r; asm("SYSCALL #110, ${path}, ${mode}"); return r; }` returned **0**,
 *    而 `return asm("SYSCALL #110, ${path}, ${mode}");` 那条取回正确的句柄 **1**。⇒ **asm 必须当表达式用**。
 *    whereas `return asm("SYSCALL #110, ${path}, ${mode}");` returned the correct handle **1**. ⇒ **asm must be used as an expression**.
 *    ⚠ **2026-10-07 更新**:本文件那层手写的 `sys_*` 已经删掉,改调 `#include <vml_sys.h>` 里的库函数
 *    —— 上面这条"`asm` 必须当表达式用"如今是**写库那一层**的规矩(见 `Lib/shared/src/vmlsys.c` 文件头)。
 *    Updated 2026-10-07: the hand-rolled `sys_*` layer is gone in favour of `#include <vml_sys.h>`, and the
 *    "use `asm` as an expression" rule above now belongs to the **library** layer (`Lib/shared/src/vmlsys.c`).
 *
 * ③ ~~**函数名别叫 `f1` / `f2` / `d0` / `l3` 这类「一个字母 + 纯数字」**。~~
 * ③ ~~**Do not name functions `f1` / `f2` / `d0` / `l3` — a single letter plus digits**.~~
 *    ✅ **已修好(2026-10-04 复验)**:寄存器形的函数名现在正常(见 `Examples/c/test/regname.c`)。
 *    ✅ **fixed (re-checked 2026-10-04)**: register-shaped function names work now (see `Examples/c/test/regname.c`).
 *    避开这类的写法**照旧保留**(能跑就别动);**新写的代码不必再避**。
 *    Avoiding such names **stays** (if it runs, do not touch it); **new code need not avoid them**.
 *
 * 版面:**不越过第 80 列** —— `tty_*` 有个已量到的缺陷,输出跨过第 80 列会插进一条定位序列、
 * Layout: **stay inside column 80** -- `tty_*` has a measured defect: crossing column 80 splices in
 * 之后整屏变乱码(复现说明见 `demo_tty.c`)。本程序最宽 46 列。
 * a positioning sequence and garbles the rest (repro note in `demo_tty.c`). This program is 46 columns at its widest.
 */

#include <vml_ui.h>
#include <stdlib.h>   /* itoa --- digits to text via the library */
#include <vml_sys.h>   /* vml_file_* —— 本文件不再自己包汇编 */
#include <http.h>      /* http_get —— DNS/套接字/剥头/状态码全在库里,不再手写 */

#define HOST    "api.open-meteo.com"
#define PORT    80
/* 请求路径(含查询串)—— 交给库里的 `http_get`,它自己拼请求行与那套头。
 * The request path (query included); `http_get` in the library builds the request line and headers. */
#define PATH    "/v1/forecast?latitude=22.54&longitude=114.06&daily=temperature_2m_max,temperature_2m_min,weather_code&forecast_days=7&timezone=Asia/Shanghai&format=csv"
#define OUTFILE "weather.txt"

#define BUFCAP    8192   /* 响应缓冲:CSV 正文实测 ~250 字节,8K 绰绰有余 */
                         /* Response buffer: the CSV body measures ~250 bytes, so 8K is plenty. */
#define MAXROUNDS 64     /* 读循环上限:防「对端不关连接」时死读。⚠ 注释不能跨行写在 #define 里 —— */
                         /* Read-loop cap: stops an endless read when the peer never closes. ⚠ Do not span lines in a #define — */
                         /* 预处理器按行吃掉宏体,注释的第二行会掉出来当代码。 */
                         /* the preprocessor eats the macro body line by line and the second comment line falls out as code. */

/* ── 系统调用返回值落点 ─────────────────────────────────────────────── */
/* -- Where syscall return values land -- */
/* ⚠ 这里原先有一层手写的 `sys_*` 包装(9 个 `asm("SYSCALL #nnn, …")` + 全局 `_sysret` 中转),
 *   外加一个 `_sysret` 全局量。2026-10-07 起改调库(`#include <vml_sys.h>`):
 *     `sys_dns`       → `vml_dns_resolve`(返回 `const char*`;**失败时是 -1 而不是 0**,见下面调用点)
 *     `sys_socket`    → `vml_socket_create(2, 1)`(AF_INET / SOCK_STREAM)
 *     `sys_connect`   → `vml_socket_connect`   `sys_send` → `vml_socket_send`
 *     `sys_recv`      → `vml_socket_recv`      `sys_sockclose` → `vml_socket_close`
 *     `sys_fileopen`  → `vml_file_open`        `sys_fileread` → `vml_file_read`
 *     `sys_filewrite` → `vml_file_write`       `sys_fileclose` → `vml_file_close`
 *   这 9 个号**库里早就包好了**,抄在这里只会多一份要跟着改的东西。
 * The hand-rolled `sys_*` wrappers and the `_sysret` global are gone; the library functions above
 * (via `#include <vml_sys.h>`) cover the same nine syscall numbers.
 */

/* ── 小工具 ─────────────────────────────────────────────────────────── */
/* -- Small helpers -- */

/* 拼请求。`format=csv` 是这一版新加的:open-meteo 支持直接回 CSV,
 * Build the request. `format=csv` is new in this version: open-meteo can return CSV directly,
 * 正文长这样(前两行是表头与空行,数据从第 3 行起):
 * and the body looks like this (the first two lines are a header row and a blank one; data starts on line 3):
 *     time,temperature_2m_max (°C),temperature_2m_min (°C),weather_code (wmo code)
 *     2026-10-07,28.1,19.8,3
 * 用 HTTP/1.0 + Connection: close ⇒ 服务端回完就关连接,读循环靠 recv 返回 0 收尾。
 * HTTP/1.0 plus Connection: close means the server closes once it has replied, and the read loop ends on recv returning 0.
 */
/* ⚠ 这里原先有三个手写的协议辅助函数:`build_request`(拼请求行 + 那套头)、
 *   `find_body`(找 `\r\n\r\n`)、`http_code`(解析状态码)—— 约 45 行。
 *   它们与库 `Lib/shared/src/http.c` 里的实现**逐字同源**(连 `User-Agent: Dolaima-VML/1.0`
 *   和 `Connection: close` 都一样),只是那份还多做了重定向与 chunked ⇒ 全部删掉,
 *   改用 `#include <http.h>` 的 `http_get`。
 * These three hand-written protocol helpers were byte-for-byte the same as the library's own
 * (`Lib/shared/src/http.c`), which additionally handles redirects and chunked bodies -- so they
 * are gone in favour of `http_get` from `#include <http.h>`.
 */

/* 把一行 CSV 切成 4 段(原地改写:逗号换成 0)。返回实际段数。
 * Split one CSV line into 4 fields (rewriting in place: commas become NUL). Returns the field count.
 * 不用指针数组:VML 的 C 前端对「数组存指针」支持得晚,四段直接写死更稳。
 * No pointer array here: VML's C front end supported "arrays of pointers" late, so four fields spelled out is safer.
 */
int split_csv(char* line, char* f1, char* f2, char* f3, char* f4) {
    int i;
    int fi;
    int o;
    char ch;
    i = 0;
    fi = 0;
    o = 0;
    f1[0] = 0;
    f2[0] = 0;
    f3[0] = 0;
    f4[0] = 0;
    while (line[i] != 0) {
        ch = line[i];
        if (ch == ',') {
            if (fi == 0) f1[o] = 0;
            else if (fi == 1) f2[o] = 0;
            else if (fi == 2) f3[o] = 0;
            fi = fi + 1;
            o = 0;
            i = i + 1;
            continue;
        }
        if (fi == 0 && o < 23) f1[o] = ch;
        else if (fi == 1 && o < 15) f2[o] = ch;
        else if (fi == 2 && o < 15) f3[o] = ch;
        else if (fi == 3 && o < 15) f4[o] = ch;
        o = o + 1;
        i = i + 1;
    }
    if (fi == 0) f1[o] = 0;
    else if (fi == 1) f2[o] = 0;
    else if (fi == 2) f3[o] = 0;
    else f4[o] = 0;
    return fi + 1;
}

/* WMO 天气代码 → 一句话。代码表来自 open-meteo 的文档(0 晴 / 1-3 云 / 45 雾 / 5x 毛毛雨 /
 * WMO weather code -> a short phrase. The table follows open-meteo's docs (0 clear / 1-3 cloud / 45 fog / 5x drizzle /
 * 6x 雨 / 7x 雪 / 8x 阵雨 / 9x 雷暴)。
 * 6x rain / 7x snow / 8x showers / 9x thunderstorm).
 */
char* wmo_label(int code, int lang) {
    if (code == 0) return lang == 0 ? "晴" : "clear";
    if (code == 1 || code == 2) return lang == 0 ? "多云" : "cloudy";
    if (code == 3) return lang == 0 ? "阴" : "overcast";
    if (code == 45 || code == 48) return lang == 0 ? "雾" : "fog";
    if (code >= 51 && code <= 57) return lang == 0 ? "毛毛雨" : "drizzle";
    if (code >= 61 && code <= 67) return lang == 0 ? "雨" : "rain";
    if (code >= 71 && code <= 77) return lang == 0 ? "雪" : "snow";
    if (code >= 80 && code <= 82) return lang == 0 ? "阵雨" : "showers";
    if (code >= 95) return lang == 0 ? "雷暴" : "storm";
    return lang == 0 ? "未知" : "unknown";
}

/* 温度条:一格 = 2℃(10℃ 起,到 34℃ 满 12 格)。纯装饰,但一眼看得出哪天热。
 * Temperature bar: one cell = 2 degrees (from 10 up to 34 for a full 12 cells). Purely decorative, but you see the hot day at a glance.
 */
void draw_bar(int tmax, int row) {
    int n;
    int i;
    n = (tmax - 10) / 2;
    if (n < 0) n = 0;
    if (n > 12) n = 12;
    tty_goto(30, row);
    tty_color(9, 0);
    i = 0;
    while (i < n) {
        tty_puts("#");
        i = i + 1;
    }
    tty_color(7, 0);
}

#define IDLE_EXIT_MS 8000

/* 画布尺寸与解析结果。**一律扁平数组**:二维数组与"指针数组"在各语言前端之间最不稳,
 * Canvas size and parsed rows. **Flat arrays only**: 2-D arrays and arrays of pointers are the least
 * portable forms across the front ends,
 * 而例程是要照着移植到 22 门语言的(本仓点名的头号坑就是"同一段代码写 22 遍")。
 * and these samples are meant to be ported to all 22 languages (writing the same code 22 times is this repo's number-one pitfall).
 */
static int SW;
static int SH;
static int ui_lang;
/* 7 天 × 12 字节(只存 `MM-DD`)*/
/* 7 days x 12 bytes (only `MM-DD`) */
static char d_dates[84];
static int  d_lo[8];
static int  d_hi[8];
static int  d_code[8];
static int  d_n;
static int  offline;        /* 1 = 没连上,显示的是本地缓存 */
/* 1 = offline, showing the local cache */

#define C_BG     0xFF0E1116
#define C_CARD   0xFF1A1F28
#define C_TITLE  0xFFE8E8F0
#define C_DIM    0xFF9AA0B0
#define C_HI     0xFFE06C50
#define C_LO     0xFF4A90D9

/* 整数 → 十进制串(`ui_2d_text` 只收字符串,第 4 层没有 `print_int` 那种东西)
 * Integer to a decimal string (`ui_2d_text` takes strings only; layer 4 has no `print_int`).
 * ⚠ 返回的是**同一个静态缓冲**,所以别在一个表达式里连着用两次 —— 要用两次就先 `strcat` 到自己的串上。
 * ⚠ It returns **one static buffer**, so never use it twice inside a single expression -- append it into your
 *   own string with `strcat` first if you need two numbers.
 */
static char numbuf[16];
static char *numstr(int v) {
    itoa(v, numbuf);
    return numbuf;
}

static void layout(void) {
    SW = ui_scr_get_w();
    SH = ui_scr_get_h();
    if (SW <= 0) SW = 360;
    if (SH <= 0) SH = 620;
}

static void draw(void) {
    int cx = SW / 2;
    int pad = SW / 18;
    int top;
    int avail;
    int cardh;
    int i;
    if (pad < 6) pad = 6;
    top = pad + 58;                       /* 标题两行 */
    /* room for the two heading lines */
    avail = SH - top - 40;
    cardh = avail / (d_n > 0 ? d_n : 1);

    ui_2d_clear(C_BG);
    ui_2d_text_styled(cx, pad, ui_lang == 0 ? "一周预报" : "One-week forecast",
                      C_TITLE, 17, VML_ANCHOR_CENTER, VML_FONT_BOLD);
    ui_2d_text(cx, pad + 26,
               offline != 0 ? (ui_lang == 0 ? "离线:读本地缓存 weather.txt" : "offline: local cache weather.txt")
                            : (ui_lang == 0 ? "数据源 api.open-meteo.com" : "source api.open-meteo.com"),
               offline != 0 ? 0xFFE0A040 : C_DIM, 11, VML_ANCHOR_CENTER);

    for (i = 0; i < d_n; i++) {
        int y = top + i * cardh;
        int h = cardh - 6;
        int midy = y + h / 2;
        char ds[8];
        char tb[16];
        int k;
        int barw;
        int innerw;

        ui_2d_rect(pad, y, SW - pad * 2, h, C_CARD, 1, 0, 8);

        /* 日期(从扁平缓冲搬进一个小局部串 —— 不取数组元素的地址) */
        /* The date (copied out of the flat buffer into a small local string -- no address-of on an array element) */
        k = 0;
        while (k < 6) { ds[k] = d_dates[i * 12 + k]; k = k + 1; }
        ds[6] = 0;
        ui_2d_text(pad + 12, midy - 8, ds, C_TITLE, 14, VML_ANCHOR_LEFT);

        /* 天气一句话 */
        /* The condition in words */
        ui_2d_text(SW - pad - 12, midy - 7, wmo_label(d_code[i], ui_lang), C_DIM, 12, VML_ANCHOR_RIGHT);

        /* 最低~最高 + 温度条(10℃ 起,34℃ 满条) */
        /* Low~high plus a temperature bar (starts at 10 C and fills at 34 C) */
        tb[0] = 0;
        strcat(tb, numstr(d_lo[i]));
        strcat(tb, "~");
        strcat(tb, numstr(d_hi[i]));
        ui_2d_text(pad + 12, midy + 10, tb, C_LO, 12, VML_ANCHOR_LEFT);

        innerw = SW - pad * 2 - 24;
        if (innerw < 40) innerw = 40;
        barw = (d_hi[i] - 10) * innerw / 24;
        if (barw < 8) barw = 8;
        if (barw > innerw) barw = innerw;
        ui_2d_rect(pad + 12, midy + 26, innerw, 6, 0xFF2A3038, 1, 0, 3);
        ui_2d_rect(pad + 12, midy + 26, barw, 6, d_hi[i] >= 30 ? C_HI : C_LO, 1, 0, 3);
    }

    ui_2d_present();      /* ⚠ 不调这句 = 黑屏 */
    /* without this call you get a black screen */
}

/* ── 主流程 ─────────────────────────────────────────────────────────── */
/* -- Main flow -- */

char rbuf[BUFCAP];   /* 整个 HTTP 响应(头 + 正文),离线时当缓存正文用 */
                     /* The whole HTTP response (headers plus body); used as the cache body when offline. */

int main(void) {
    char  req[640];
    char* ip;
    int  fd;
    int  port;
    int  code;
    int  total;
    int  n;
    int  want;
    int  addr;
    int  head;
    int  blen;
    int  rounds;
    int  fh;
    int  mode;
    int  wrote;
    int  lang;     /* 界面语言:开局查一次(ui_scr_get_language 是 syscall,别每帧调) */
                   /* UI language: queried once at start (ui_scr_get_language is a syscall, do not call it every frame) */

    lang = ui_scr_get_language();
    offline = 0;
    blen = 0;
    head = 0;

    print_str(lang == 0 ? "=== VML 天气预报 ===\n" : "=== VML Weather Report ===\n");
    print_str(lang == 0 ? "主机 " : "Host "); print_str(HOST);
    print_str(lang == 0 ? "  文件 " : "  file "); print_str(OUTFILE); print_str("\n");

    /* ① 域名 → IP —— ⚠ **这一步仍要自己做**:`http.c` 头部明说它**不代劳** DNS
     *   (宿主 `#334` 只吃 `IPAddress.Parse` 认得的那串点分十进制,喂它域名直接抛异常),
     *   而且在库里做会把一次 `http_get` 变成两次网络往返 ⇒ 它把这条留给了调用方。
     * Resolve the name yourself: `http.c` deliberately does **not** do DNS (`#334` only parses a
     * dotted-quad, and doing it in the library would make one `http_get` into two round trips).
     * ⚠ 失败时宿主把 R0 置 **-1**(不是 0)⇒ 判据必须是 `<= 0`。 */
    if (offline == 0) {
        ip = (char*)vml_dns_resolve(HOST);
        if ((int)ip <= 0) {
            print_str(lang == 0 ? "[警告] DNS 解析失败(转为离线)\n" : "[WARN] DNS lookup failed (going offline)\n");
            offline = 1;
        } else {
            print_str("[1/4] DNS  "); print_str(ip); print_str("\n");
        }
    }

    /* ②③ 抓数据 —— 建套接字、连接、发请求、收完整响应、剥头、状态码**全在库里**
     *   (`Lib/shared/src/http.c` 的 `http_get`;它拼的那套请求头与本例程原先手写的**逐字相同**,
     *     另外还多做了重定向与 chunked 解码)。返回值 = **HTTP 状态码**或负错误码。
     * The rest of the fetch (socket, connect, send, read, strip headers, status code) lives in the
     * library's `http_get`, which returns the **HTTP status code** or a negative error code. */
    if (offline == 0) {
        code = http_get_to(ip, PORT, HOST, PATH, rbuf, BUFCAP);   /* 连 IP、Host 头写域名 */
                                                                  /* connect to the IP, `Host:` carries the name */
        if (code != 200) {
            print_str(lang == 0 ? "[警告] 抓取失败(转为离线)\n" : "[WARN] fetch failed (going offline)\n");
            offline = 1;
        } else {
            blen = strlen(rbuf);         /* `http_get` 只往回装**正文**(响应头已经剥掉了)*/
                                         /* `http_get` stores **only the body** (headers already stripped) */
            head = 0;                    /* 正文从第 0 字节起 —— 下面写文件用 `rbuf + head` */
            print_str("[1/4] HTTP "); print_int(code);
            print_str(lang == 0 ? "  正文 " : "  body "); print_int(blen);
            print_str(lang == 0 ? " 字节\n" : " bytes\n");
        }
    }

    /* ④ 剥头 → 写文件(离线时跳过)*/
    /* ④ Strip the headers and write the file (skipped when offline). */
    if (offline == 0) {
        mode = 1;                        /* 1 = 只写(FileMode.Create),见 VMLRuntime 的 #110 */
                                         /* 1 = write only (FileMode.Create), see #110 in VMLRuntime. */
        fh = vml_file_open(OUTFILE, mode);
        if (fh < 0) {
            print_str(lang == 0 ? "[警告] 打不开 " : "[WARN] cannot open "); print_str(OUTFILE);
            print_str(lang == 0 ? "(手机上路径必须是相对的)\n" : " (paths must be relative on the phone)\n");
        } else {
            addr = (int)rbuf + head;
            wrote = vml_file_write(fh, addr, blen);
            vml_file_close(fh);
            print_str("[4/4] HTTP "); print_int(code);
            print_str(lang == 0 ? "  正文 " : "  body "); print_int(blen); print_str(lang == 0 ? " 字节" : " bytes");
            print_str(lang == 0 ? "  写入 " : "  wrote "); print_int(wrote); print_str(lang == 0 ? " 字节 -> " : " bytes -> ");
            print_str(OUTFILE); print_str("\n");
            /* 正文留在 `rbuf` 里(从 `head` 起),下面直接显示,不必再读一次文件 */
            /* The body stays in `rbuf` (from `head` on), so the display below needs no second read */
        }
    }

    /* ⑤ 离线回落:读上一次留下的 weather.txt(相对路径,各宿主自己解析)
     * ⑤ Offline fallback: read the weather.txt left by the last run (a relative path each host resolves itself)
     * ⚠ `mode = 0` 是**只读**:写成 1 会把缓存文件清空再读,拿到一份空数据。
     * ⚠ `mode = 0` is **read-only**: passing 1 would truncate the cache file first and hand back nothing.
     */
    if (offline != 0) {
        mode = 0;
        fh = vml_file_open(OUTFILE, mode);
        if (fh < 0) {
            print_str(lang == 0 ? "[失败] 离线且没有本地缓存 " : "[FAIL] offline and no local cache ");
            print_str(OUTFILE);
            print_str(lang == 0 ? " —— 先在联网的机器上跑一次,或把预报 CSV 放到同目录。\n"
                                : " -- run this once on a networked machine, or drop the forecast CSV next to it.\n");
            return 1;
        }
        blen = vml_file_read(fh, (int)rbuf, BUFCAP - 1);
        vml_file_close(fh);
        if (blen <= 0) {
            print_str(lang == 0 ? "[失败] 缓存文件是空的\n" : "[FAIL] the cache file is empty\n");
            return 2;
        }
        rbuf[blen] = 0;
        head = 0;                        /* 缓存里没有响应头,正文从 0 开始 */
                                         /* The cache has no response headers, so the body starts at 0 */
    }

    /* ⑥ 解析成结构化的「天」*/
    /* 6. Parse the CSV into structured days */
    d_n = 0;
    {
        int i;
        int li;
        int row_ok;
        char ch;
        char line[96];
        char f1[24];
        char f2[16];
        char f3[16];
        char f4[16];

        li = 0;
        i = head;
        while (i <= head + blen) {
            ch = (i < head + blen) ? rbuf[i] : 10;
            if (ch != 10) {
                if (li < 95) { line[li] = ch; li = li + 1; }
                i = i + 1;
                continue;
            }
            line[li] = 0;
            li = 0;
            i = i + 1;
            /* 只认「日期形状」的行:第 1 段必须是 `YYYY-MM-DD`。
             * Only lines shaped like a date count: the first field must be `YYYY-MM-DD`.
             * ⚠ 别只判"数字开头" —— open-meteo 的 CSV **第 2 行是经纬度那行**
             * ⚠ Do not settle for "starts with a digit" -- line 2 of open-meteo's CSV is the latitude/longitude row
             *   (`22.530754,114.08714,8.0,28800,...`),数字开头,判错了会多出一行"日期"、
             *   (`22.530754,114.08714,8.0,28800,...`), which does start with a digit; judging it wrong adds a bogus
             *   平均温也跟着歪(实测:多算一天、平均 19~40℃)。
             *   "day" and skews the averages (measured: one extra day, average 19-40 C).
             */
            row_ok = 1;
            if (line[0] < '0' || line[0] > '9') row_ok = 0;
            if (line[4] != '-' || line[7] != '-') row_ok = 0;
            if (row_ok == 0) continue;
            if (split_csv(line, f1, f2, f3, f4) < 4) continue;
            if (d_n >= 7) break;
            /* 日期只留 `MM-DD`(年前两位对一周预报没意义,省下的宽度留给温度)
             * Keep only `MM-DD` from the date (the century is noise for a week and the width is better spent on temperature) */
            d_dates[d_n * 12 + 0] = f1[5];
            d_dates[d_n * 12 + 1] = f1[6];
            d_dates[d_n * 12 + 2] = f1[7];
            d_dates[d_n * 12 + 3] = f1[8];
            d_dates[d_n * 12 + 4] = f1[9];
            d_dates[d_n * 12 + 5] = 0;
            d_lo[d_n] = atoi(f3);
            d_hi[d_n] = atoi(f2);
            d_code[d_n] = atoi(f4);
            d_n = d_n + 1;
        }
    }
    if (d_n == 0) {
        print_str(lang == 0 ? "[失败] 没解析出数据行(CSV 格式变了吗?)\n"
                            : "[FAIL] no data rows parsed (did the CSV format change?)\n");
        return 3;
    }

    /* ⑦ 开窗 → 画 → 消息循环 */
    /* Open a window, draw, then run the message loop */
    ui_lang = lang;
    layout();
    ui_win_open_ex("weather.c", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    layout();
    draw();
    {
        int msg[4];
        int t;
        int t0;
        int done;
        t0 = ui_time_get_ms();
        done = 0;
        while (!done && ui_win_is_closed() == 0) {
            t = ui_msg_wait(msg, 200);
            if (t == VML_MSG_NONE) {
                if (ui_time_get_ms() - t0 > IDLE_EXIT_MS) done = 1;   /* 空闲超时(验收用)*/
                                                          /* idle timeout (for automated checks) */
                continue;
            }
            t0 = ui_time_get_ms();
            if (t == VML_MSG_KEYDOWN) {
                int k = msg[1];
                if (k == VML_KEY_ESCAPE || k == 'q' || k == 'Q') done = 1;
                else if (k == VML_KEY_LEFT || k == VML_KEY_RIGHT || k == VML_KEY_UP || k == VML_KEY_DOWN) draw();
                continue;
            }
            if (t == VML_MSG_WINDOWORIENT || t == VML_MSG_WINDOWRESIZE) { layout(); draw(); continue; }
            if (t == VML_MSG_WINDOWCLOSE) done = 1;
        }
    }
    return 0;
}