From bed6a95485afb3e352226bac36566b39871d8d12 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 19:44:18 +0800 Subject: [PATCH 01/38] =?UTF-8?q?feat(tuku):=20=E5=9B=BE=E5=BA=93=20M3=20?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E2=80=94=E2=80=94=E4=B9=A6=E5=BA=93=E6=B5=8F?= =?UTF-8?q?=E8=A7=88/=E4=B8=BB=E9=A2=98=E5=90=8C=E6=AD=A5/=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E5=99=A8=E4=B8=8E=E9=94=81=E5=B1=8F=E6=8E=A5=E7=BC=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增顶层 tuku/ 工程(package dev.tuku.app、二进制 tuku):app.c/internal 头/logic/ui 分层、app.json、build/pack 脚本 - 书库:从 TF 卡根浏览文件夹,含图目录=一本书;logic/library 纯 C 扫描+自然排序(page2/cache(mtime 失效+LRU) - 列表 meow_listview 虚拟化 + meow_scroll fling/snap;行=缩略图+目录名+页数 - 主题:全部语义色 + meow_theme_add_listener 即时重绘;明暗/风格族两态可读 - 导航:meow_view page push/pop、TopAppBar ⚙→设置;reader/settings/lock 最小桩 - 接缝:mr_reader_get/set_direction(默认 RTL)、lock_view_should_show()(桩 0) - 宿主 gcc 测试 46 项 PASS;交叉构建静态 MIPS32r2 ELF、-Wall -Wextra 零警告 --- .gitignore | 6 + README.md | 11 + tuku/app.c | 137 ++++++++ tuku/app.json | 3 + tuku/build_tuku.sh | 18 + tuku/logic/library.c | 247 ++++++++++++++ tuku/logic/library.h | 63 ++++ tuku/logic/reader.c | 14 + tuku/logic/reader.h | 23 ++ tuku/pack_tuku.sh | 14 + tuku/test/run_tests.sh | 8 + tuku/test/test_library.c | 223 +++++++++++++ tuku/tuku_internal.h | 64 ++++ tuku/ui/library/library.c | 650 ++++++++++++++++++++++++++++++++++++ tuku/ui/lock/lock.c | 55 +++ tuku/ui/reader/reader.c | 85 +++++ tuku/ui/settings/settings.c | 55 +++ 17 files changed, 1676 insertions(+) create mode 100644 tuku/app.c create mode 100644 tuku/app.json create mode 100755 tuku/build_tuku.sh create mode 100644 tuku/logic/library.c create mode 100644 tuku/logic/library.h create mode 100644 tuku/logic/reader.c create mode 100644 tuku/logic/reader.h create mode 100755 tuku/pack_tuku.sh create mode 100755 tuku/test/run_tests.sh create mode 100644 tuku/test/test_library.c create mode 100644 tuku/tuku_internal.h create mode 100644 tuku/ui/library/library.c create mode 100644 tuku/ui/lock/lock.c create mode 100644 tuku/ui/reader/reader.c create mode 100644 tuku/ui/settings/settings.c diff --git a/.gitignore b/.gitignore index 3e5f418..85651e7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,12 @@ test_umbrella examples/hello/hello examples/hello/render_test*.png +# ---- 图库产物 ---- +tuku/tuku +tuku/*.mpk +tuku/build/ +tuku/test/test_library + # ---- 杂项 ---- *.orig *.log diff --git a/README.md b/README.md index 2460816..cc28f12 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ MeowOS-SDK/ │ ├── ui/ # 每页一个 view 文件(home/demo/list/full/chrome) │ ├── logic/ # 纯 C 业务逻辑(不依赖 meow_ui.h,宿主可裸测) │ └── build_hello.sh # 构建 hello(链 meow_ui.a + libz.a + -lpthread) +├── tuku/ # 图库:漫画书库 App(M3 骨架:主题同步/目录浏览/阅读器桩) +│ ├── app.c # main + 装配 + meow_ui_run 回调路由 + 主题监听 +│ ├── logic/ # 纯 C:目录扫描/自然排序、阅读方向状态(宿主可裸测) +│ ├── ui/ # library / reader / settings / lock 每页一个 view +│ ├── build_tuku.sh # 交叉构建(SDK 用 ${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}) +│ └── pack_tuku.sh # 出包 dev.tuku.app.mpk └── docs/ ├── APP_DEV_GUIDE.md # 第三方开发上手指南(环境 → 最小 App → 构建打包 → 签名安装 → 调试) └── MEOW_APP_SDK.md # SDK 设计全量规范 @@ -46,6 +52,11 @@ cd sdk # 2) 构建示例 App hello cd ../examples/hello ./build_hello.sh # 产出 examples/hello/hello(静态 MIPS32r2 ELF) + +# 3) 构建图库(SDK 用绝对路径,可用 MEOW_SDK_DIR 覆盖) +cd ../../tuku +./build_tuku.sh # 产出 tuku/tuku(静态 MIPS32r2 ELF) +./pack_tuku.sh # 产出 tuku/dev.tuku.app.mpk ``` ## SDK 能力速览 diff --git a/tuku/app.c b/tuku/app.c new file mode 100644 index 0000000..7f380ca --- /dev/null +++ b/tuku/app.c @@ -0,0 +1,137 @@ +/* app.c — 图库装配层:main + 建页 + meow_ui_run 回调路由 + 主题监听。 + * + * 契约(docs/APP_DEV_GUIDE.md §2-§3): + * - main 只做 meow_ui_init → 建 view → meow_ui_run → 清理;退出走 meow_ui_request_exit; + * - 页面切换统一走 meow_view_page_push/pop(activity 栈 + on_enter/on_leave 唯一入口); + * - 物理键不映射返回/确认(on_key 仅日志);屏底上滑由 SDK 系统手势接管,App 不拦截; + * - 左缘返回:书库内逐级上移,根目录才退出。 + * + * 主题同步:注册 meow_theme_add_listener;明暗/风格族切换时更新页面底色并整帧 invalidate, + * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 + */ +#include "tuku_internal.h" +#include "meow_ui_nav.h" + +#include + +static void on_sigterm(int s) +{ + (void)s; + meow_ui_request_exit(); +} + +/* 向 launcher 上报当前页栈深/顶层,供系统导航栈跟随。各页 on_enter 调用。 */ +void mr_nav_report(void) +{ + int depth = meow_ui_activity_depth(); + if (depth <= 0) depth = 1; + int id = meow_ui_activity_active(); + const char *top = (id == ACT_READER) ? "reader" + : (id == ACT_SETTINGS) ? "settings" + : (id == ACT_LOCK) ? "lock" + : "library"; + meow_ui_nav_report((uint32_t)depth, top); +} + +/* 主题切换:页面底色 + 整帧重绘(控件与 on_draw 全部从 meow_theme_get() 取色)。 */ +static void app_theme_changed(meow_theme_mode m) +{ + (void)m; + meow_ui_set_page_bg(meow_theme_get()->bg); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +static int app_active(void) +{ + return meow_ui_activity_active(); +} + +static void app_on_key(int keycode, int is_down) +{ + (void)keycode; (void)is_down; /* 物理键是系统语义,不做返回/确认映射 */ + meow_log(MEOW_LOG_DEBUG, "key"); +} + +static void app_on_tap(int x, int y) +{ + switch (app_active()) { + case ACT_LIBRARY: library_on_tap(x, y); break; + case ACT_READER: reader_on_tap(x, y); break; + default: break; + } +} + +static void app_on_swipe(int dir, int dx, int dy) +{ + (void)dy; + if (dir == MEOW_SWIPE_LEFT) { + if (app_active() == ACT_LIBRARY) { + if (!library_go_up()) { + meow_log(MEOW_LOG_INFO, "exit:swipe"); + meow_ui_request_exit(); + } + } else { + meow_view_page_pop(); + } + return; + } + /* SDK 的 on_swipe 把位移量放在第二参(第三参为 0):UP/DOWN 时 dx=纵向位移。 */ + if (app_active() == ACT_LIBRARY) library_on_swipe(dir, dx); +} + +static void app_on_tick(void) +{ + library_on_tick(); +} + +static void app_on_swipe_progress(int x_start, int dx) +{ + meow_ui_swipe_hint(x_start + dx); +} + +static void app_on_touch_down(int x, int y) +{ + if (app_active() == ACT_LIBRARY) library_on_touch_down(x, y); +} + +static void app_on_touch_move(int x, int y) +{ + if (app_active() == ACT_LIBRARY) library_on_touch_move(x, y); +} + +int main(void) +{ + meow_log(MEOW_LOG_INFO, "start"); + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { + meow_log(MEOW_LOG_ERROR, "init failed"); + return 1; + } + + meow_theme_add_listener(app_theme_changed); + meow_ui_set_page_bg(meow_theme_get()->bg); + meow_ui_nav_init(); + signal(SIGTERM, on_sigterm); + + /* 先建全部页面(on_create 建各自控件/扫描书库),再按启动锁状态进根页。 */ + meow_view *library = library_view_create(); + meow_view *reader = reader_view_create(); + meow_view *settings = settings_view_create(); + meow_view *lock = lock_view_create(); + + meow_view_page_push(lock_view_should_show() ? ACT_LOCK : ACT_LIBRARY); + + meow_ui_events ev = { + app_on_key, app_on_tap, app_on_swipe, app_on_tick, + app_on_swipe_progress, app_on_touch_down, app_on_touch_move, + }; + meow_ui_run(&ev); + + meow_view_free(lock); + meow_view_free(settings); + meow_view_free(reader); + meow_view_free(library); + meow_ui_shutdown(); + meow_log(MEOW_LOG_INFO, "exit"); + return 0; +} diff --git a/tuku/app.json b/tuku/app.json new file mode 100644 index 0000000..1b5005d --- /dev/null +++ b/tuku/app.json @@ -0,0 +1,3 @@ +{"name":"图库","exec":"/usr/data/mnt/sd_0/MeowAPPs/dev.tuku.app/tuku", + "package":"dev.tuku.app","version":"0.1","version_code":1,"framework":1, + "fonts":"common","route.reader.fullscreen":1} diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh new file mode 100755 index 0000000..1509a3b --- /dev/null +++ b/tuku/build_tuku.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# build_tuku.sh — 构建图库(静态 MIPS32r2,链 meow_ui.a + libz.a + -lpthread)。 +# +# SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖; +# 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/PicBrowser。 +# 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。 +set -e +cd "$(dirname "$0")" +CC="${MEOW_CC:-$HOME/.tools/zig-mipsel-cc}" +SYSROOT="${MEOW_SYSROOT:-$HOME/.tools/mipsel-linux-musleabi-sysroot}" +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" + +"$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ + app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ + logic/library.c logic/reader.c \ + "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o tuku +ls -la tuku +file tuku diff --git a/tuku/logic/library.c b/tuku/logic/library.c new file mode 100644 index 0000000..38a5d40 --- /dev/null +++ b/tuku/logic/library.c @@ -0,0 +1,247 @@ +/* logic/library.c — 书库目录扫描 + 自然排序(纯 C)。 + * + * 扫描分两步: + * 1) scan_info():递归探测一个目录是否「最终含图」,并带回直接图片数 / 含图子目录数 / + * 缩略图源(优先本目录自然序第一张,其次第一个含图子目录); + * 2) mr_library_scan():只对目标目录的**直接子目录**调用 scan_info(),把 nav=1 的目录 + * 收进结果并自然排序——因此根视图不会列出后代条目,进入子目录时再重新扫描该层。 + * + * 内存:条目数组倍增分配;路径/名称定长,随 mr_library_free() 释放。 + */ +#include "library.h" + +#include +#include +#include +#include +#include +#include +#include + +/* 系统目录不参与书库(SD 根上的 MeowOS 目录树)。 */ +static int ignored_dir_name(const char *name) +{ + static const char *const skip[] = { "MeowAPPs", "MeowData", "MeowOS", "lost+found" }; + if (!name || name[0] == '.') return 1; + for (unsigned i = 0; i < sizeof skip / sizeof skip[0]; i++) + if (strcmp(name, skip[i]) == 0) return 1; + return 0; +} + +static int copy_str(char *dst, int dstsz, const char *src) +{ + if (!dst || dstsz <= 0) return -1; + if (!src) { dst[0] = '\0'; return 0; } + int n = (int)strlen(src); + if (n >= dstsz) return -1; + memcpy(dst, src, (size_t)n + 1); + return 0; +} + +static int join_path(char *out, int outsz, const char *dir, const char *name) +{ + if (!out || outsz <= 0 || !dir || !name) return -1; + int n; + size_t dl = strlen(dir); + if (dl > 0 && dir[dl - 1] == '/') + n = snprintf(out, (size_t)outsz, "%s%s", dir, name); + else + n = snprintf(out, (size_t)outsz, "%s/%s", dir, name); + return (n > 0 && n < outsz) ? 0 : -1; +} + +int mr_path_is_image(const char *name) +{ + if (!name) return 0; + const char *dot = strrchr(name, '.'); + if (!dot || dot == name) return 0; + dot++; + return strcasecmp(dot, "png") == 0 || strcasecmp(dot, "jpg") == 0 || + strcasecmp(dot, "jpeg") == 0 || strcasecmp(dot, "bmp") == 0; +} + +int mr_natural_cmp(const char *a, const char *b) +{ + const unsigned char *pa = (const unsigned char *)a; + const unsigned char *pb = (const unsigned char *)b; + if (!a || !b) return (a == b) ? 0 : (a ? 1 : -1); + + while (*pa && *pb) { + if (isdigit(*pa) && isdigit(*pb)) { + const unsigned char *za = pa, *zb = pb; + while (*pa == '0') pa++; + while (*pb == '0') pb++; + const unsigned char *da = pa, *db = pb; + while (isdigit(*pa)) pa++; + while (isdigit(*pb)) pb++; + + size_t la = (size_t)(pa - da), lb = (size_t)(pb - db); + if (la != lb) return la < lb ? -1 : 1; + if (la > 0) { + int c = memcmp(da, db, la); + if (c != 0) return c < 0 ? -1 : 1; + } + size_t oa = (size_t)(da - za), ob = (size_t)(db - zb); + if (oa != ob) return oa < ob ? -1 : 1; + continue; + } + + int ca = tolower(*pa), cb = tolower(*pb); + if (ca != cb) return ca < cb ? -1 : 1; + pa++; + pb++; + } + if (*pa) return 1; + if (*pb) return -1; + return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); +} + +typedef struct scan_info { + int pages; /* 直接图片数 */ + int subdirs; /* 含图子目录数 */ + int nav; /* 1=本目录最终含图(可直接看或可导航到图) */ + char cover[MR_PATH_MAX]; /* 缩略图源 */ + char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ +} scan_info; + +static void scan_dir(const char *path, int depth, int max_depth, scan_info *si) +{ + memset(si, 0, sizeof *si); + DIR *d = opendir(path); + if (!d) return; + + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (ignored_dir_name(name)) continue; + + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, path, name) != 0) continue; + + struct stat st; + if (lstat(full, &st) != 0) continue; + if (S_ISLNK(st.st_mode)) continue; + + if (S_ISDIR(st.st_mode)) { + if (depth >= max_depth) continue; + scan_info child; + scan_dir(full, depth + 1, max_depth, &child); + if (!child.nav) continue; + si->subdirs++; + if (!si->cover[0] && child.cover[0]) + copy_str(si->cover, (int)sizeof si->cover, child.cover); + } else if (S_ISREG(st.st_mode) && mr_path_is_image(name)) { + si->pages++; + if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { + copy_str(si->first_name, (int)sizeof si->first_name, name); + copy_str(si->cover, (int)sizeof si->cover, full); + } + } + } + closedir(d); + si->nav = (si->pages > 0 || si->subdirs > 0); +} + +void mr_library_free(mr_library *lib) +{ + if (!lib) return; + free(lib->items); + lib->items = NULL; + lib->count = 0; + lib->cap = 0; +} + +static int lib_add(mr_library *lib, const mr_library_entry *e) +{ + if (lib->count >= lib->cap) { + int cap = lib->cap ? lib->cap * 2 : 16; + mr_library_entry *ni = (mr_library_entry *)realloc(lib->items, + (size_t)cap * sizeof *ni); + if (!ni) return -1; + lib->items = ni; + lib->cap = cap; + } + lib->items[lib->count++] = *e; + return 0; +} + +static int entry_cmp(const void *pa, const void *pb) +{ + const mr_library_entry *a = (const mr_library_entry *)pa; + const mr_library_entry *b = (const mr_library_entry *)pb; + return mr_natural_cmp(a->name, b->name); +} + +int mr_library_scan(mr_library *lib, const char *dir, int max_depth) +{ + if (!lib) return -1; + mr_library_free(lib); + if (!dir || !dir[0]) return -1; + + if (max_depth <= 0 || max_depth > MR_LIB_MAX_DEPTH) max_depth = MR_LIB_MAX_DEPTH; + + DIR *d = opendir(dir); + if (!d) return -1; + + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (ignored_dir_name(name)) continue; + + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + + struct stat st; + if (lstat(full, &st) != 0) continue; + if (S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; + + scan_info si; + scan_dir(full, 1, max_depth, &si); + if (!si.nav) continue; + + mr_library_entry e; + memset(&e, 0, sizeof e); + if (copy_str(e.name, (int)sizeof e.name, name) != 0) continue; + if (copy_str(e.path, (int)sizeof e.path, full) != 0) continue; + copy_str(e.cover, (int)sizeof e.cover, si.cover); + e.pages = si.pages; + e.subdirs = si.subdirs; + e.has_images = (si.pages > 0); + if (e.has_images) + snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", e.pages, + e.pages == 1 ? "" : "s"); + else + snprintf(e.subtitle, sizeof e.subtitle, "%d folder%s", e.subdirs, + e.subdirs == 1 ? "" : "s"); + if (lib_add(lib, &e) != 0) { + closedir(d); + mr_library_free(lib); + return -1; + } + } + closedir(d); + + if (lib->count > 1) qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); + return lib->count; +} + +int mr_path_parent(const char *path, char *out, int outsz) +{ + if (!path || !out || outsz <= 0) return -1; + size_t n = strlen(path); + while (n > 1 && path[n - 1] == '/') n--; + if (n <= 1) { + if (outsz < 2) return -1; + out[0] = '/'; + out[1] = '\0'; + return 0; + } + while (n > 0 && path[n - 1] != '/') n--; + while (n > 1 && path[n - 1] == '/') n--; + if (n >= (size_t)outsz) return -1; + memcpy(out, path, n); + out[n] = '\0'; + return 0; +} diff --git a/tuku/logic/library.h b/tuku/logic/library.h new file mode 100644 index 0000000..e97d7a6 --- /dev/null +++ b/tuku/logic/library.h @@ -0,0 +1,63 @@ +/* logic/library.h — 漫画书库扫描/排序(纯 C,不依赖 meow_ui.h)。 + * + * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 + * 书库语义: + * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; + * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); + * - 行的 pages=该目录直接图片数;subdirs=其下含图子目录数(中间目录用于副标题); + * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover; + * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 + * + * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 + * (. 开头、MeowAPPs/MeowData/MeowOS/lost+found)不参与书库。符号链接跳过(防环)。 + */ +#ifndef MR_LIBRARY_H +#define MR_LIBRARY_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_PATH_MAX 512 +#define MR_NAME_MAX 256 +#define MR_LIB_MAX_DEPTH 8 + +typedef struct mr_library_entry { + char name[MR_NAME_MAX]; /* 目录名(行主标题) */ + char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ + char cover[MR_PATH_MAX]; /* 缩略图源图(空=none) */ + char subtitle[48]; /* 预先格式化好的 "N pages" / "N folders" */ + int pages; /* 本目录直接图片数 */ + int subdirs; /* 本目录下含图子目录数 */ + int has_images; /* 1=本目录直接含图(点入进 reader) */ +} mr_library_entry; + +typedef struct mr_library { + mr_library_entry *items; + int count; + int cap; +} mr_library; + +/* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ +int mr_path_is_image(const char *name); + +/* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; + * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ +int mr_natural_cmp(const char *a, const char *b); + +/* 扫描 dir 的直接子目录,写入 lib(先释放 lib 旧内容)。 + * 返回条目数(>=0);dir 不可读返回 -1(lib 仍为空且可安全 mr_library_free)。 + * max_depth<=0 或超过 MR_LIB_MAX_DEPTH 时按上限处理。 */ +int mr_library_scan(mr_library *lib, const char *dir, int max_depth); + +/* 释放条目数组并清零(可重复调用)。 */ +void mr_library_free(mr_library *lib); + +/* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。 + * 成功 0;参数非法/输出缓冲不足 -1。 */ +int mr_path_parent(const char *path, char *out, int outsz); + +#ifdef __cplusplus +} +#endif +#endif /* MR_LIBRARY_H */ diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c new file mode 100644 index 0000000..d84d5d0 --- /dev/null +++ b/tuku/logic/reader.c @@ -0,0 +1,14 @@ +/* logic/reader.c — 阅读方向状态(默认 RTL;M4 的阅读器直接复用)。 */ +#include "reader.h" + +static int g_direction = MR_DIR_RTL; + +int mr_reader_get_direction(void) +{ + return g_direction; +} + +void mr_reader_set_direction(int dir) +{ + if (dir == MR_DIR_RTL || dir == MR_DIR_LTR) g_direction = dir; +} diff --git a/tuku/logic/reader.h b/tuku/logic/reader.h new file mode 100644 index 0000000..5571cdd --- /dev/null +++ b/tuku/logic/reader.h @@ -0,0 +1,23 @@ +/* logic/reader.h — 阅读器纯逻辑接缝(M4 填充实现,M3 先落状态 API)。 + * + * 阅读方向默认 RTL(日式漫画从右往左翻);设置页/阅读器可切 LTR。 + * 本模块保持纯 C(不 include meow_ui.h),宿主可直接测。 + */ +#ifndef MR_READER_H +#define MR_READER_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_DIR_RTL 1 /* 默认:右到左(日漫) */ +#define MR_DIR_LTR 0 /* 左到右 */ + +int mr_reader_get_direction(void); +/* 只接受 MR_DIR_RTL / MR_DIR_LTR;其它值忽略(保持原方向)。 */ +void mr_reader_set_direction(int dir); + +#ifdef __cplusplus +} +#endif +#endif /* MR_READER_H */ diff --git a/tuku/pack_tuku.sh b/tuku/pack_tuku.sh new file mode 100755 index 0000000..b41b8dc --- /dev/null +++ b/tuku/pack_tuku.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# pack_tuku.sh — 出包 dev.tuku.app.mpk(tar.gz,根目录 dev.tuku.app/,含 app.json + tuku)。 +# .mpk 文件名茎 = app.json.package = 安装目录名(dev.tuku.app)。 +set -e +cd "$(dirname "$0")" +[ -x tuku ] || ./build_tuku.sh + +rm -rf build/dev.tuku.app +mkdir -p build/dev.tuku.app +cp app.json tuku build/dev.tuku.app/ + +rm -f dev.tuku.app.mpk +( cd build && tar czf ../dev.tuku.app.mpk dev.tuku.app ) +ls -la dev.tuku.app.mpk diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh new file mode 100755 index 0000000..26a31da --- /dev/null +++ b/tuku/test/run_tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# run_tests.sh — 宿主 gcc 构建并运行图库 logic 层测试(不链 SDK、不需要设备)。 +set -e +cd "$(dirname "$0")/.." +gcc -Wall -Wextra -O1 -I. \ + test/test_library.c logic/library.c logic/reader.c \ + -o test/test_library +./test/test_library diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c new file mode 100644 index 0000000..65b0e2e --- /dev/null +++ b/tuku/test/test_library.c @@ -0,0 +1,223 @@ +/* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。 + * + * 覆盖: + * T1 自然排序:数字段数值序(page2 +#include +#include +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs("x", f); + return fclose(f); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static const mr_library_entry *find_entry(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + return NULL; +} + +static void test_natural(void) +{ + CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); + CHECK(mr_natural_cmp("page10", "page2") > 0, "page10 > page2"); + CHECK(mr_natural_cmp("page2", "page02") < 0, "fewer leading zeros first"); + CHECK(mr_natural_cmp("x001", "x1") > 0, "x1 < x001"); + CHECK(mr_natural_cmp("Chapter 9", "Chapter 10") < 0, "chapter 9 < 10"); + CHECK(mr_natural_cmp("img2a", "img10b") < 0, "mixed img2a < img10b"); + CHECK(mr_natural_cmp("a", "a1") < 0, "prefix shorter first"); + CHECK(mr_natural_cmp("abc", "abc") == 0, "identical equal"); + CHECK(mr_natural_cmp("ABC", "abc") != 0, "case-only tie is deterministic"); + CHECK(mr_natural_cmp("A2", "a10") < 0, "case-insensitive primary then numeric"); +} + +static void test_ext(void) +{ + CHECK(mr_path_is_image("a.png") == 1, "png"); + CHECK(mr_path_is_image("a.JPG") == 1, "JPG uppercase"); + CHECK(mr_path_is_image("a.jpeg") == 1, "jpeg"); + CHECK(mr_path_is_image("a.BmP") == 1, "BMP mixed case"); + CHECK(mr_path_is_image("a.gif") == 0, "gif unsupported"); + CHECK(mr_path_is_image("a.txt") == 0, "txt unsupported"); + CHECK(mr_path_is_image("png") == 0, "no extension"); + CHECK(mr_path_is_image(".png") == 0, "dotfile-ish no stem"); +} + +static void test_parent(void) +{ + char out[MR_PATH_MAX]; + CHECK(mr_path_parent("/usr/data/mnt/sd_0/a/b", out, sizeof out) == 0 && + strcmp(out, "/usr/data/mnt/sd_0/a") == 0, "parent nested"); + CHECK(mr_path_parent("/usr/data/mnt/sd_0/a/", out, sizeof out) == 0 && + strcmp(out, "/usr/data/mnt/sd_0") == 0, "parent trailing slash"); + CHECK(mr_path_parent("/a", out, sizeof out) == 0 && strcmp(out, "/") == 0, "parent root child"); + CHECK(mr_path_parent("/", out, sizeof out) == 0 && strcmp(out, "/") == 0, "parent root"); +} + +static void test_scan(void) +{ + char tmpl[] = "./mr_test_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return; + + char p[MR_PATH_MAX]; + + snprintf(p, sizeof p, "%s/Series 10", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 10/page1.png", root); touch_file(p); + snprintf(p, sizeof p, "%s/Series 10/page2.jpg", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Series 2", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 2/page10.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Series 02", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 02/page1.bmp", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Series 3", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 3/page1.gif", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Empty", root); mkdir_p(p); + + snprintf(p, sizeof p, "%s/Nested/Deeper", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Nested/Deeper/a.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/.hidden", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/.hidden/h.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/MeowAPPs", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/MeowAPPs/m.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); /* 根目录直接图片不成为行 */ + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n == 4, "scan count=%d (want 4)", n); + CHECK(lib.count == n, "count matches"); + if (lib.count == 4) { + CHECK(strcmp(lib.items[0].name, "Nested") == 0, "sort[0]=Nested got %s", lib.items[0].name); + CHECK(strcmp(lib.items[1].name, "Series 2") == 0, "sort[1]=Series 2 got %s", lib.items[1].name); + CHECK(strcmp(lib.items[2].name, "Series 02") == 0, "sort[2]=Series 02 got %s", lib.items[2].name); + CHECK(strcmp(lib.items[3].name, "Series 10") == 0, "sort[3]=Series 10 got %s", lib.items[3].name); + } + + const mr_library_entry *e = find_entry(&lib, "Series 10"); + CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "Series 10 pages=2"); + CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "Series 10 subtitle '%s'", e ? e->subtitle : ""); + CHECK(e && strstr(e->cover, "page1.png") != NULL, "Series 10 cover natural-first '%s'", + e ? e->cover : ""); + + e = find_entry(&lib, "Series 2"); + CHECK(e && e->pages == 1 && e->has_images == 1, "Series 2 pages=1"); + + e = find_entry(&lib, "Nested"); + CHECK(e && e->pages == 0 && e->has_images == 0 && e->subdirs == 1, "Nested intermediate"); + CHECK(e && strcmp(e->subtitle, "1 folder") == 0, "Nested subtitle '%s'", e ? e->subtitle : ""); + CHECK(e && strstr(e->cover, "Deeper/a.png") != NULL, "Nested cover from descendant '%s'", + e ? e->cover : ""); + + CHECK(find_entry(&lib, "Empty") == NULL, "empty dir excluded"); + CHECK(find_entry(&lib, "Series 3") == NULL, "gif-only dir excluded"); + CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); + CHECK(find_entry(&lib, "MeowAPPs") == NULL, "system dir excluded"); + + /* 深度 1:Nested 不再可探测到 Deeper 的图,应被排除。 */ + mr_library lib1; + memset(&lib1, 0, sizeof lib1); + CHECK(mr_library_scan(&lib1, root, 1) == 3, "max_depth=1 excludes Nested"); + mr_library_free(&lib1); + + mr_library_free(&lib); + + /* 不可读目录:返回 -1,lib 可安全 free。 */ + mr_library bad; + memset(&bad, 0, sizeof bad); + CHECK(mr_library_scan(&bad, "/no/such/dir/tuku", MR_LIB_MAX_DEPTH) == -1, "missing dir -> -1"); + CHECK(bad.count == 0, "missing dir empty"); + mr_library_free(&bad); + + rm_rf(root); +} + +static void test_direction(void) +{ + mr_reader_set_direction(MR_DIR_RTL); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "default RTL"); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "switch LTR"); + mr_reader_set_direction(99); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "invalid ignored"); + mr_reader_set_direction(MR_DIR_RTL); +} + +int main(void) +{ + test_natural(); + test_ext(); + test_parent(); + test_scan(); + test_direction(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h new file mode 100644 index 0000000..caad8db --- /dev/null +++ b/tuku/tuku_internal.h @@ -0,0 +1,64 @@ +/* tuku_internal.h — 图库应用内部共享头(只 extern 声明,不定义)。 + * + * 分层(照 examples/hello): + * - logic/ 纯 C(library 扫描/自然排序、reader 方向状态),不依赖 meow_ui.h,宿主可裸测; + * - ui/ 每页一个 view 文件(library/reader/settings/lock),只碰 SDK; + * - app.c main + 装配 + meow_ui_run 回调路由 + 主题监听。 + * + * 页面:ACT_LIBRARY(0) 根页(保留状态栏)、ACT_READER(1) 全屏、ACT_SETTINGS(2)、ACT_LOCK(3)。 + * 并行开发接缝(后续任务只填自己的文件,不改 app.c/internal.h): + * - M4 阅读器:ui/reader/reader.c 实现,方向状态用 logic/reader 的 mr_reader_get/set_direction; + * 书库点入含图目录经 mr_reader_open_dir(path) 进入本页。 + * - M5 锁屏/PIN:ui/lock/lock.c 实现 lock_view_should_show();本批桩返回 0(不锁)。 + */ +#ifndef TUKU_INTERNAL_H +#define TUKU_INTERNAL_H + +#include + +#include "meow_ui.h" +#include "meow_ui_view.h" +#include "logic/library.h" +#include "logic/reader.h" + +/* ---- Activity 归属(稳定 id,< MEOW_VIEW_BY_ID_MAX) ---- */ +#define ACT_LIBRARY 0 +#define ACT_READER 1 +#define ACT_SETTINGS 2 +#define ACT_LOCK 3 + +#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库根 = TF 卡根 */ + +/* ---- 视图控制器(biz-UI 绑定点) ---- */ +typedef struct mr_page_ctrl { + meow_view *view; + int active; +} mr_page_ctrl; + +/* ---- 书库页(ui/library/library.c) ---- */ +meow_view *library_view_create(void); +int library_at_root(void); /* 1=当前在 MR_ROOT_DIR */ +int library_go_up(void); /* 返回上一级;1=已上移,0=已在根 */ +void library_on_tap(int x, int y); +void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ +void library_on_touch_down(int x, int y); +void library_on_touch_move(int x, int y); +void library_on_tick(void); /* 收下后台缩略图结果并局部补位 */ + +/* ---- 阅读器页(ui/reader/reader.c,M4 填充) ---- */ +meow_view *reader_view_create(void); +void reader_on_tap(int x, int y); +void mr_reader_open_dir(const char *path); /* 书库→阅读器的桩导航接口 */ +const char *mr_reader_dir(void); /* 当前待打开目录(骨架显示用) */ + +/* ---- 设置页(ui/settings/settings.c) ---- */ +meow_view *settings_view_create(void); + +/* ---- 锁屏页(ui/lock/lock.c,M5 填充) ---- */ +meow_view *lock_view_create(void); +int lock_view_should_show(void); /* 1=启动先显示锁屏;本批桩返回 0 */ + +/* ---- App 级(app.c) ---- */ +void mr_nav_report(void); /* 向 launcher 上报当前页栈深/顶层 */ + +#endif /* TUKU_INTERNAL_H */ diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c new file mode 100644 index 0000000..28fcdf3 --- /dev/null +++ b/tuku/ui/library/library.c @@ -0,0 +1,650 @@ +/* ui/library/library.c — 图库根页(ACT_LIBRARY): + * + * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 + * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册)。 + * 行=缩略图 + 目录名 + 页数;目录由 logic/library 扫描并自然排序。 + * + * 流畅度(用户指令优先): + * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, + * 完成后在 on_tick 补位并只 invalidate 列表视口; + * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData//cache + * (BMP 原始缩略图,文件名含源文件 mtime+size,源文件变化自动失效;≥64 张按 + * mtime LRU 删除); + * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 + * + * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 + * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 + */ +#include "tuku_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MR_THUMB_MAX 24 +#define MR_THUMB_DIM 96 +#define MR_TOPAPPBAR_H 56 +#define MR_DEFAULT_ROW_H 56 +#define MR_CACHE_MAX_FILES 64 + +#define MR_REQ_MAX 64 +#define MR_RES_MAX 16 + +typedef struct thumb_req { + int gen; + int idx; + char cover[MR_PATH_MAX]; +} thumb_req; + +typedef struct thumb_res { + int gen; + int idx; + meow_img *img; +} thumb_res; + +static mr_page_ctrl g_ctrl; +static mr_library g_lib; +static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR; + +static meow_widget *g_tb; +static meow_widget *g_lv; +static meow_scroll *g_scroll; +static int g_list_x, g_list_y, g_list_w, g_list_h; + +static struct { + int idx; + meow_img *img; +} g_thumbs[MR_THUMB_MAX]; +static int g_thumb_n; + +/* ---- 异步缩略图请求/结果队列 ---- */ +static pthread_t g_worker; +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; +static int g_worker_on; +static int g_worker_stop; +static int g_gen; + +static thumb_req g_req[MR_REQ_MAX]; +static int g_req_head, g_req_tail, g_req_n; +static char g_inflight[MR_PATH_MAX]; + +static thumb_res g_res[MR_RES_MAX]; +static int g_res_n; + +/* ---- 磁盘缩略图缓存 ---- */ +static char g_cache_dir[MR_PATH_MAX]; +static int g_cache_on; + +static int g_drag; +static int g_dragged; +static int g_drag_last_y; + +/* ============================================================ */ +/* 磁盘缓存(BMP 原始缩略图;文件名含源 mtime+size 做失效) */ +/* ============================================================ */ +static unsigned long long fnv1a64(const char *s) +{ + unsigned long long h = 1469598103934665603ULL; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h ^= (unsigned long long)*p; + h *= 1099511628211ULL; + } + return h; +} + +static void cache_init(void) +{ + const char *data = meow_config_dir(); + if (!data || !data[0]) return; + + int n = snprintf(g_cache_dir, sizeof g_cache_dir, "%s", data); + if (n <= 0 || n >= (int)sizeof g_cache_dir) { g_cache_dir[0] = '\0'; return; } + + char *slash = strrchr(g_cache_dir, '/'); + if (slash && strcmp(slash, "/data") == 0) *slash = '\0'; + + size_t len = strlen(g_cache_dir); + n = snprintf(g_cache_dir + len, sizeof g_cache_dir - len, "/cache"); + if (n <= 0 || n >= (int)(sizeof g_cache_dir - len)) { g_cache_dir[0] = '\0'; return; } + + if (mkdir(g_cache_dir, 0755) != 0 && errno != EEXIST) { + g_cache_dir[0] = '\0'; + return; + } + g_cache_on = 1; +} + +static void cache_path(char *out, int outsz, const char *cover, const struct stat *st) +{ + unsigned long long h = fnv1a64(cover); + snprintf(out, (size_t)outsz, "%s/%016llx_%lld_%lld.bmp", g_cache_dir, h, + (long long)st->st_mtime, (long long)st->st_size); +} + +/* LRU:缓存文件数超过 MR_CACHE_MAX_FILES 时删最旧(读命中会 utimes 刷新 mtime)。 + * 每次只删一个最旧文件、最多扫 8 轮,避免在 14MB 内存口径下分配大数组。 */ +static void cache_prune(void) +{ + if (!g_cache_on || !g_cache_dir[0]) return; + for (int pass = 0; pass < 8; pass++) { + DIR *d = opendir(g_cache_dir); + if (!d) return; + int count = 0, found = 0; + char oldest[MR_PATH_MAX + 96]; + oldest[0] = '\0'; + time_t oldest_t = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *dot = strrchr(de->d_name, '.'); + if (!dot || strcmp(dot, ".bmp") != 0) continue; + char full[MR_PATH_MAX + 96]; + int n = snprintf(full, sizeof full, "%s/%s", g_cache_dir, de->d_name); + if (n <= 0 || n >= (int)sizeof full) continue; + struct stat st; + if (stat(full, &st) != 0) continue; + count++; + if (!found || st.st_mtime < oldest_t) { + found = 1; + oldest_t = st.st_mtime; + snprintf(oldest, sizeof oldest, "%s", full); + } + } + closedir(d); + if (count <= MR_CACHE_MAX_FILES || !found) break; + unlink(oldest); + } +} + +static void put_u16(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)(v & 0xFF); + p[1] = (unsigned char)((v >> 8) & 0xFF); +} +static void put_u32(unsigned char *p, uint32_t v) +{ + p[0] = (unsigned char)(v & 0xFF); + p[1] = (unsigned char)((v >> 8) & 0xFF); + p[2] = (unsigned char)((v >> 16) & 0xFF); + p[3] = (unsigned char)((v >> 24) & 0xFF); +} + +/* 写 32bpp BI_RGB BMP(stb_image 可读回)。失败返回 -1(缓存只是加速,静默降级)。 */ +static int write_bmp(const char *path, const meow_img *im) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *px = meow_img_pixels(im); + if (!path || w <= 0 || h <= 0 || w > MR_THUMB_DIM || h > MR_THUMB_DIM || !px) return -1; + + uint32_t stride = (uint32_t)w * 4; + uint32_t imgsz = stride * (uint32_t)h; + unsigned char fh[14] = { 0 }; + unsigned char ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put_u32(fh + 2, 14 + 40 + imgsz); + put_u32(fh + 10, 14 + 40); + put_u32(ih + 0, 40); + put_u32(ih + 4, (uint32_t)w); + put_u32(ih + 8, (uint32_t)h); + put_u16(ih + 12, 1); + put_u16(ih + 14, 32); + put_u32(ih + 20, imgsz); + put_u32(ih + 24, 2835); + put_u32(ih + 28, 2835); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = (fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih); + unsigned char row[MR_THUMB_DIM * 4]; + for (int y = h - 1; ok && y >= 0; y--) { + const unsigned char *src = px + (size_t)y * (size_t)w * 4; + for (int x = 0; x < w; x++) { + row[x * 4 + 0] = src[x * 4 + 2]; + row[x * 4 + 1] = src[x * 4 + 1]; + row[x * 4 + 2] = src[x * 4 + 0]; + row[x * 4 + 3] = src[x * 4 + 3]; + } + if (fwrite(row, 1, stride, f) != stride) ok = 0; + } + if (fclose(f) != 0) ok = 0; + if (!ok) unlink(path); + return ok ? 0 : -1; +} + +/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 */ +static meow_img *thumb_load(const char *cover) +{ + struct stat st; + if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; + + char cp[MR_PATH_MAX + 96]; + if (g_cache_on && g_cache_dir[0]) { + cache_path(cp, (int)sizeof cp, cover, &st); + if (access(cp, R_OK) == 0) { + meow_img *im = meow_img_load_file(cp); + if (im) { + utimes(cp, NULL); /* LRU 读命中刷新 */ + return im; + } + unlink(cp); /* 损坏缓存 */ + } + } + + meow_img *im = meow_img_load_file(cover); + if (!im) return NULL; + meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); + if (small) { meow_img_free(im); im = small; } + + if (g_cache_on && g_cache_dir[0]) { + if (write_bmp(cp, im) == 0) cache_prune(); + } + return im; +} + +static void *thumb_worker(void *arg) +{ + (void)arg; + for (;;) { + thumb_req req; + pthread_mutex_lock(&g_lock); + while (!g_worker_stop && g_req_n == 0) + pthread_cond_wait(&g_cv, &g_lock); + if (g_worker_stop) { pthread_mutex_unlock(&g_lock); break; } + req = g_req[g_req_head]; + g_req_head = (g_req_head + 1) % MR_REQ_MAX; + g_req_n--; + snprintf(g_inflight, sizeof g_inflight, "%s", req.cover); + pthread_mutex_unlock(&g_lock); + + meow_img *im = thumb_load(req.cover); + + pthread_mutex_lock(&g_lock); + g_inflight[0] = '\0'; + if (im && !g_worker_stop && g_res_n < MR_RES_MAX) { + g_res[g_res_n].gen = req.gen; + g_res[g_res_n].idx = req.idx; + g_res[g_res_n].img = im; + g_res_n++; + } else if (im) { + meow_img_free(im); + } + pthread_mutex_unlock(&g_lock); + } + return NULL; +} + +static void worker_start(void) +{ + if (g_worker_on) return; + g_worker_stop = 0; + if (pthread_create(&g_worker, NULL, thumb_worker, NULL) == 0) g_worker_on = 1; +} + +static void worker_stop(void) +{ + if (!g_worker_on) return; + pthread_mutex_lock(&g_lock); + g_worker_stop = 1; + pthread_cond_broadcast(&g_cv); + pthread_mutex_unlock(&g_lock); + pthread_join(g_worker, NULL); + g_worker_on = 0; + + pthread_mutex_lock(&g_lock); + for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + g_res_n = 0; + g_req_n = 0; + g_req_head = g_req_tail = 0; + pthread_mutex_unlock(&g_lock); +} + +/* 请求缩略图:bind 只投递,不阻塞首绘/滚动。 */ +static void thumb_enqueue(int idx, const char *cover) +{ + if (!g_worker_on || !cover || !cover[0]) return; + pthread_mutex_lock(&g_lock); + if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } + if (strcmp(g_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + for (int i = 0; i < g_req_n; i++) { + int p = (g_req_head + i) % MR_REQ_MAX; + if (strcmp(g_req[p].cover, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + } + if (g_req_n >= MR_REQ_MAX) { pthread_mutex_unlock(&g_lock); return; } + g_req[g_req_tail].gen = g_gen; + g_req[g_req_tail].idx = idx; + snprintf(g_req[g_req_tail].cover, sizeof g_req[g_req_tail].cover, "%s", cover); + g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; + g_req_n++; + pthread_cond_signal(&g_cv); + pthread_mutex_unlock(&g_lock); +} + +/* ============================================================ */ +/* 缩略图内存缓存 */ +/* ============================================================ */ +static void thumbs_free(void) +{ + for (int i = 0; i < g_thumb_n; i++) meow_img_free(g_thumbs[i].img); + g_thumb_n = 0; +} + +static meow_img *thumb_find(int idx) +{ + for (int i = 0; i < g_thumb_n; i++) + if (g_thumbs[i].idx == idx) return g_thumbs[i].img; + return NULL; +} + +static int thumb_put(int idx, meow_img *im) +{ + if (g_thumb_n >= MR_THUMB_MAX) return 0; + g_thumbs[g_thumb_n].idx = idx; + g_thumbs[g_thumb_n].img = im; + g_thumb_n++; + return 1; +} + +/* ============================================================ */ +/* 列表虚拟化数据源 */ +/* ============================================================ */ +static int lib_count(void *ctx) +{ + (void)ctx; + return g_lib.count; +} + +static void lib_bind(int idx, meow_lv_item *item, void *ctx) +{ + (void)ctx; + memset(item, 0, sizeof *item); + if (idx < 0 || idx >= g_lib.count) return; + mr_library_entry *e = &g_lib.items[idx]; + item->title = e->name; + item->subtitle = e->subtitle; + item->right = MEOW_LIST_RIGHT_ARROW; + item->enabled = 1; + item->user = e; + + meow_img *im = thumb_find(idx); + if (im) { + item->icon_kind = MEOW_LIST_ICON_IMG; + item->icon = im; + } else { + item->icon_kind = e->has_images ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; + if (e->cover[0]) thumb_enqueue(idx, e->cover); + } +} + +/* ============================================================ */ +/* 布局 / 列表重建 */ +/* ============================================================ */ +static int lib_top_y(void) +{ + return meow_ui_statusbar_height() + MR_TOPAPPBAR_H; +} + +static void list_rebuild(void) +{ + if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } + if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } + g_list_x = 0; + g_list_y = lib_top_y(); + g_list_w = meow_ui_width(); + g_list_h = meow_ui_height() - g_list_y; + if (g_list_h < 1) g_list_h = 1; + + g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, 0, NULL, NULL, + MEOW_LIST_SEL_NONE); + if (!g_lv) return; + meow_listview_set_vsource(g_lv, lib_count, lib_bind, NULL); + meow_listview_set_item_renderer(g_lv, 2, 1, 1); + meow_listview_set_item_tier(g_lv, 2); + meow_listview_set_empty_text(g_lv, "No folders with PNG/JPEG/BMP"); + + g_scroll = meow_scroll_wrap(g_lv, 1); + if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); +} + +static void update_topbar(void) +{ + if (!g_tb) return; + const char *title = "图库"; + if (strcmp(g_cur_dir, MR_ROOT_DIR) != 0) { + const char *slash = strrchr(g_cur_dir, '/'); + if (slash && slash[1]) title = slash + 1; + } + meow_topappbar_set_title(g_tb, title); + meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, MR_ROOT_DIR) != 0); +} + +static void list_invalidate(void) +{ + meow_ui_invalidate(g_list_x, g_list_y, g_list_w, g_list_h); +} + +/* 目录切换:作废旧异步结果、释放内存缩略图、重新扫描、重建列表(新池强制重新 bind)。 */ +static void reset_async(void) +{ + pthread_mutex_lock(&g_lock); + g_gen++; + g_req_n = 0; + g_req_head = g_req_tail = 0; + for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + g_res_n = 0; + pthread_mutex_unlock(&g_lock); +} + +static void load_dir(const char *dir) +{ + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); + reset_async(); + thumbs_free(); + if (mr_library_scan(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH) < 0) { + mr_library_free(&g_lib); + meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir); + } + list_rebuild(); + update_topbar(); + g_drag = 0; + g_dragged = 0; + if (g_ctrl.active) + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +int library_at_root(void) +{ + return strcmp(g_cur_dir, MR_ROOT_DIR) == 0; +} + +int library_go_up(void) +{ + if (library_at_root()) return 0; + char parent[MR_PATH_MAX]; + if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; + /* 不越过书库根(父级仍须在根目录之下或等于根)。 */ + if (strcmp(parent, MR_ROOT_DIR) != 0 && + strncmp(parent, MR_ROOT_DIR "/", sizeof(MR_ROOT_DIR)) != 0) + snprintf(parent, sizeof parent, "%s", MR_ROOT_DIR); + load_dir(parent); + return 1; +} + +/* ============================================================ */ +/* 事件 */ +/* ============================================================ */ +void library_on_tap(int x, int y) +{ + if (!g_ctrl.active) return; + g_drag = 0; + g_dragged = 0; + + if (g_tb) { + int hit = meow_topappbar_hit(g_tb, x, y); + if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (hit >= 0) { meow_view_page_push(ACT_SETTINGS); return; } + } + if (!g_lv) return; + int idx = meow_listview_hit(g_lv, x, y); + if (idx < 0 || idx >= g_lib.count) return; + mr_library_entry *e = &g_lib.items[idx]; + if (e->has_images) mr_reader_open_dir(e->path); + else load_dir(e->path); +} + +void library_on_swipe(int dir, int dist) +{ + if (!g_ctrl.active || !g_lv) return; + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + if (g_dragged) { /* 本次手势已实时跟手滚动,抬手只补 fling */ + g_dragged = 0; + g_drag = 0; + if (g_scroll) meow_scroll_fling(g_scroll); + return; + } + int del = (dir == MEOW_SWIPE_UP) ? dist : -dist; + if (del == 0) del = (dir == MEOW_SWIPE_UP) ? 120 : -120; + if (g_scroll) { + meow_scroll_track(g_scroll, del, meow_ui_time_ms()); + meow_scroll_fling(g_scroll); + } else { + meow_listview_scroll_by(g_lv, del); + } + list_invalidate(); +} + +void library_on_touch_down(int x, int y) +{ + if (!g_ctrl.active || !g_lv) return; + /* 屏底 28px 是系统「上滑回桌面」热区:不从这里启动列表拖拽,避免拦截系统手势。 */ + if (y >= meow_ui_height() - 28) return; + if (x < g_list_x || x >= g_list_x + g_list_w || + y < g_list_y || y >= g_list_y + g_list_h) return; + g_drag = 1; + g_dragged = 0; + g_drag_last_y = y; +} + +void library_on_touch_move(int x, int y) +{ + (void)x; + if (!g_ctrl.active || !g_drag || !g_lv) return; + int dy = g_drag_last_y - y; + if (dy == 0) return; + if (dy > 1 || dy < -1) g_dragged = 1; + g_drag_last_y = y; + if (g_scroll) meow_scroll_track(g_scroll, dy, meow_ui_time_ms()); + else meow_listview_scroll_by(g_lv, dy); + list_invalidate(); +} + +/* 16ms tick:收下后台解码好的缩略图并局部补位。 */ +void library_on_tick(void) +{ + if (!g_ctrl.active) return; + thumb_res got[MR_RES_MAX]; + int n = 0; + pthread_mutex_lock(&g_lock); + n = g_res_n; + for (int i = 0; i < n; i++) got[i] = g_res[i]; + g_res_n = 0; + pthread_mutex_unlock(&g_lock); + + int changed = 0; + for (int i = 0; i < n; i++) { + if (got[i].gen == g_gen && got[i].idx >= 0 && got[i].idx < g_lib.count && + !thumb_find(got[i].idx) && thumb_put(got[i].idx, got[i].img)) + changed = 1; + else + meow_img_free(got[i].img); + } + if (changed) list_invalidate(); +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ +static void library_page_create(meow_view *v, void *ctx) +{ + mr_page_ctrl *c = (mr_page_ctrl *)ctx; + c->view = v; + + cache_init(); + worker_start(); + + int w = meow_ui_width(); + int sb = meow_ui_statusbar_height(); + g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); + if (g_tb) { + int ai = meow_topappbar_add_action(g_tb, ""); + if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_SETTINGS); + } + load_dir(MR_ROOT_DIR); +} + +static void library_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(0); /* 图库保留状态栏(reader 退出时若未复原也在此兜底) */ + meow_ui_set_page_bg(meow_theme_get()->bg); + + int sb = meow_ui_statusbar_height(); + if (g_tb) meow_widget_set_rect(g_tb, 0, sb, meow_ui_width(), MR_TOPAPPBAR_H); + if (g_lv) { + int y = sb + MR_TOPAPPBAR_H; + int h = meow_ui_height() - y; + if (h < 1) h = 1; + meow_widget_set_rect(g_lv, 0, y, meow_ui_width(), h); + g_list_y = y; + g_list_w = meow_ui_width(); + g_list_h = h; + } + g_drag = 0; + g_dragged = 0; + update_topbar(); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void library_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; + g_drag = 0; + g_dragged = 0; + if (g_scroll) meow_scroll_stop(g_scroll); +} + +static void library_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + worker_stop(); + if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } + if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + thumbs_free(); + mr_library_free(&g_lib); +} + +meow_view *library_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = library_page_create, + .on_enter = library_page_enter, + .on_leave = library_page_leave, + .on_destroy = library_page_destroy, + }; + return meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c new file mode 100644 index 0000000..0fd5626 --- /dev/null +++ b/tuku/ui/lock/lock.c @@ -0,0 +1,55 @@ +/* ui/lock/lock.c — 锁屏页(ACT_LOCK)M3 桩 + M5 接缝。 + * + * M5 在此实现「APP 启动 PIN」:lock_view_should_show() 读配置决定是否锁, + * 锁屏页负责 PIN 输入/校验后 page_pop 回书库。本批桩恒返回 0(不锁),页面仅可编译占位。 + */ +#include "tuku_internal.h" + +static mr_page_ctrl g_ctrl; + +int lock_view_should_show(void) +{ + return 0; /* M5:读 APP 启动 PIN 配置后返回 1 */ +} + +static void lock_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = meow_ui_width(); + int h = meow_ui_height(); + int cy = h / 2; + + meow_ui_text_center(w / 2, cy - 80, "LOCKED", th->font_title, th->primary); + meow_ui_text_center(w / 2, cy - 36, "Startup PIN (M5)", th->font_body, th->onSurface); + meow_ui_text_center(w / 2, cy + 8, "PIN entry will be implemented in M5", + th->font_body, th->onSurfaceVariant); +} + +static void lock_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(0); + meow_ui_set_page_bg(meow_theme_get()->bg); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void lock_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; +} + +meow_view *lock_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = lock_page_enter, + .on_leave = lock_page_leave, + .on_draw = lock_draw, + }; + return meow_view_create(ACT_LOCK, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c new file mode 100644 index 0000000..1e2ebe6 --- /dev/null +++ b/tuku/ui/reader/reader.c @@ -0,0 +1,85 @@ +/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏)M3 骨架。 + * + * M4 在此实现翻页/图片显示;本批只落可编译桩 + 既有接缝: + * - 书库经 mr_reader_open_dir(path) 导航进来(app.c/library.c 不改); + * - 方向状态走 logic/reader 的 mr_reader_get/set_direction(默认 RTL)。 + * 绘制走 view on_draw(注册表驱动),主题切换由 app.c 的 listener invalidate 全屏重画。 + */ +#include "tuku_internal.h" + +#include +#include + +static mr_page_ctrl g_ctrl; +static char g_dir[MR_PATH_MAX]; + +const char *mr_reader_dir(void) +{ + return g_dir; +} + +void mr_reader_open_dir(const char *path) +{ + if (!path) return; + snprintf(g_dir, sizeof g_dir, "%s", path); + meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); + meow_view_page_push(ACT_READER); +} + +void reader_on_tap(int x, int y) +{ + (void)x; (void)y; + if (!g_ctrl.active) return; + mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL ? MR_DIR_LTR : MR_DIR_RTL); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = meow_ui_width(); + const char *dir = g_dir[0] ? g_dir : "(no directory)"; + size_t n = strlen(dir); + if (n > 64) dir += n - 64; + + meow_ui_text_center(w / 2, 160, "READER", th->font_title, th->primary); + meow_ui_text_center(w / 2, 200, "(M4 will render pages here)", th->font_body, + th->onSurfaceVariant); + meow_ui_text_center(w / 2, 280, "Folder:", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, 308, dir, th->font_body, th->onSurface); + meow_ui_text_center(w / 2, 372, + mr_reader_get_direction() == MR_DIR_RTL ? "Direction: RTL" : "Direction: LTR", + th->font_body, th->onSurface); + meow_ui_text_center(w / 2, 420, "tap = toggle direction", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, 448, "left-edge swipe = back", th->font_body, th->onSurfaceVariant); +} + +static void reader_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(1); /* app.json route.reader.fullscreen 的运行时兜底 */ + meow_ui_set_page_bg(meow_theme_get()->bg); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void reader_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; + meow_ui_set_fullscreen(0); +} + +meow_view *reader_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = reader_page_enter, + .on_leave = reader_page_leave, + .on_draw = reader_draw, + }; + return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} diff --git a/tuku/ui/settings/settings.c b/tuku/ui/settings/settings.c new file mode 100644 index 0000000..57e1a4f --- /dev/null +++ b/tuku/ui/settings/settings.c @@ -0,0 +1,55 @@ +/* ui/settings/settings.c — 设置页(ACT_SETTINGS)M3 骨架。 + * + * M5 在此实现 APP 启动 PIN 等设置项;本批只落可编译桩,入口为书库 TopAppBar ⚙。 + * 返回:左缘返回(app.c SWIPE_LEFT → page_pop)。 + */ +#include "tuku_internal.h" + +static mr_page_ctrl g_ctrl; + +static void settings_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = meow_ui_width(); + int sb = meow_ui_statusbar_height(); + + meow_ui_fill_rect(0, sb, w, 56, th->surfaceContainer); + meow_ui_text(w / 2, sb + 16, "Settings", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, sb + 100, "APP options", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, sb + 140, "Startup PIN — M5", th->font_body, th->onSurface); + meow_ui_text_center(w / 2, sb + 180, "Reader direction — M4", th->font_body, + th->onSurface); + meow_ui_text_center(w / 2, sb + 240, "Theme follows the system automatically", + th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, sb + 300, "left-edge swipe = back", th->font_body, + th->onSurfaceVariant); +} + +static void settings_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(0); + meow_ui_set_page_bg(meow_theme_get()->bg); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void settings_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; +} + +meow_view *settings_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = settings_page_enter, + .on_leave = settings_page_leave, + .on_draw = settings_draw, + }; + return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} From f4c21c74a1ca1e349abb3a7f252686bedb126c7a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:09:24 +0800 Subject: [PATCH 02/38] =?UTF-8?q?fix(tuku):=20=E4=B9=A6=E5=BA=93=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=90=AF=E7=94=A8=20MD3=20=E5=AE=B9=E5=99=A8=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=B9=B6=E8=A1=A5=20M4/M5=20=E6=8E=A5=E7=BC=9D?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1:list_rebuild() 在 set_item_renderer/set_item_tier 后调用 meow_listview_set_container(g_lv,1), 修正 SDK 默认 container=0(扁平满幅+分隔线)与任务要求的 MD3 圆角卡观感不一致 - P2:reader/lock/settings 桩头补接缝注释——on_create 在 meow_view_create 内运行且 activity 栈空, 非根页控件须 lazy 到 on_enter 或 push→建→pop;lock 另注明必须由自身 view on_event 消费 SWIPE, 否则左缘滑动绕过 PIN - 复验:宿主测试 46/46 PASS;交叉构建静态 MIPS32r2 ELF、-Wall -Wextra 零警告 --- tuku/ui/library/library.c | 3 +++ tuku/ui/lock/lock.c | 10 ++++++++++ tuku/ui/reader/reader.c | 6 ++++++ tuku/ui/settings/settings.c | 6 ++++++ 4 files changed, 25 insertions(+) diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 28fcdf3..50b69e4 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -408,6 +408,9 @@ static void list_rebuild(void) meow_listview_set_vsource(g_lv, lib_count, lib_bind, NULL); meow_listview_set_item_renderer(g_lv, 2, 1, 1); meow_listview_set_item_tier(g_lv, 2); + /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: + * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ + meow_listview_set_container(g_lv, 1); meow_listview_set_empty_text(g_lv, "No folders with PNG/JPEG/BMP"); g_scroll = meow_scroll_wrap(g_lv, 1); diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c index 0fd5626..a188f10 100644 --- a/tuku/ui/lock/lock.c +++ b/tuku/ui/lock/lock.c @@ -2,6 +2,16 @@ * * M5 在此实现「APP 启动 PIN」:lock_view_should_show() 读配置决定是否锁, * 锁屏页负责 PIN 输入/校验后 page_pop 回书库。本批桩恒返回 0(不锁),页面仅可编译占位。 + * + * 接缝注意(M5): + * ① meow_view 的 on_create 在 meow_view_create 内运行(此时 activity 栈空);本页是非根页, + * 控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 + * meow_ui_activity_push(ACT_LOCK) → 建控件 → meow_ui_activity_pop() + * (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY, + * 锁屏控件会从书库页被命中(§6.1 跨页泄漏)。 + * ② 锁屏必须经自身 view 的 on_event 消费 SWIPE:meow_view_dispatch_event 先于 App 的 + * on_swipe(sdk/meow_ui_view.h:83-88);否则 app.c 对非 library 页的左缘滑动无条件 + * page_pop,PIN 会被绕过。 */ #include "tuku_internal.h" diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 1e2ebe6..33de900 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -4,6 +4,12 @@ * - 书库经 mr_reader_open_dir(path) 导航进来(app.c/library.c 不改); * - 方向状态走 logic/reader 的 mr_reader_get/set_direction(默认 RTL)。 * 绘制走 view on_draw(注册表驱动),主题切换由 app.c 的 listener invalidate 全屏重画。 + * + * 接缝注意(M4):meow_view 的 on_create 在 meow_view_create 内运行(此时 activity 栈空), + * 本页是非根页——控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 + * meow_ui_activity_push(ACT_READER) → 建控件 → meow_ui_activity_pop() + * (examples/hello/ui/demo/demo.c:515-522);否则控件 owner 绑到 ACT_LIBRARY, + * 造成跨页命中/绘制泄漏(§6.1)。 */ #include "tuku_internal.h" diff --git a/tuku/ui/settings/settings.c b/tuku/ui/settings/settings.c index 57e1a4f..ef92ca2 100644 --- a/tuku/ui/settings/settings.c +++ b/tuku/ui/settings/settings.c @@ -2,6 +2,12 @@ * * M5 在此实现 APP 启动 PIN 等设置项;本批只落可编译桩,入口为书库 TopAppBar ⚙。 * 返回:左缘返回(app.c SWIPE_LEFT → page_pop)。 + * + * 接缝注意(M5):meow_view 的 on_create 在 meow_view_create 内运行(activity 栈空); + * 本页是非根页,控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 + * meow_ui_activity_push(ACT_SETTINGS) → 建控件 → meow_ui_activity_pop() + * (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY,设置控件会从 + * 书库页被命中(§6.1 跨页泄漏)。 */ #include "tuku_internal.h" From 2c60de141f08d6d42fda8e1a67c161ed4124ef53 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:22:44 +0800 Subject: [PATCH 03/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E5=8C=85=E5=90=8D?= =?UTF-8?q?=E5=AE=9A=E4=B8=BA=20os.meow.picbrowser=EF=BC=88=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8C=87=E5=AE=9A=E7=B3=BB=E7=BB=9F=E5=89=8D=E7=BC=80?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94app.json=20package/exec=E3=80=81buil?= =?UTF-8?q?d/pack=20=E4=BA=A7=E7=89=A9=E5=90=8D=E4=B8=8E=20README/.gitigno?= =?UTF-8?q?re=20=E5=90=8C=E6=AD=A5=EF=BC=9B=E4=BA=A4=E5=8F=89=E6=9E=84?= =?UTF-8?q?=E5=BB=BA+=E6=89=93=E5=8C=85=E5=A4=8D=E9=AA=8C=E9=80=9A?= =?UTF-8?q?=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- README.md | 4 ++-- tuku/app.json | 4 ++-- tuku/build_tuku.sh | 8 ++++---- tuku/pack_tuku.sh | 18 +++++++++--------- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 85651e7..8e90778 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ examples/hello/hello examples/hello/render_test*.png # ---- 图库产物 ---- -tuku/tuku +tuku/picbrowser tuku/*.mpk tuku/build/ tuku/test/test_library diff --git a/README.md b/README.md index cc28f12..37db47f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ MeowOS-SDK/ │ ├── logic/ # 纯 C:目录扫描/自然排序、阅读方向状态(宿主可裸测) │ ├── ui/ # library / reader / settings / lock 每页一个 view │ ├── build_tuku.sh # 交叉构建(SDK 用 ${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}) -│ └── pack_tuku.sh # 出包 dev.tuku.app.mpk +│ └── pack_tuku.sh # 出包 os.meow.picbrowser.mpk └── docs/ ├── APP_DEV_GUIDE.md # 第三方开发上手指南(环境 → 最小 App → 构建打包 → 签名安装 → 调试) └── MEOW_APP_SDK.md # SDK 设计全量规范 @@ -56,7 +56,7 @@ cd ../examples/hello # 3) 构建图库(SDK 用绝对路径,可用 MEOW_SDK_DIR 覆盖) cd ../../tuku ./build_tuku.sh # 产出 tuku/tuku(静态 MIPS32r2 ELF) -./pack_tuku.sh # 产出 tuku/dev.tuku.app.mpk +./pack_tuku.sh # 产出 tuku/os.meow.picbrowser.mpk ``` ## SDK 能力速览 diff --git a/tuku/app.json b/tuku/app.json index 1b5005d..3d7e62a 100644 --- a/tuku/app.json +++ b/tuku/app.json @@ -1,3 +1,3 @@ -{"name":"图库","exec":"/usr/data/mnt/sd_0/MeowAPPs/dev.tuku.app/tuku", - "package":"dev.tuku.app","version":"0.1","version_code":1,"framework":1, +{"name":"图库","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.picbrowser/picbrowser", + "package":"os.meow.picbrowser","version":"0.1","version_code":1,"framework":1, "fonts":"common","route.reader.fullscreen":1} diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index 1509a3b..43dc07f 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -2,7 +2,7 @@ # build_tuku.sh — 构建图库(静态 MIPS32r2,链 meow_ui.a + libz.a + -lpthread)。 # # SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖; -# 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/PicBrowser。 +# 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/os.meow.picbrowser。 # 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。 set -e cd "$(dirname "$0")" @@ -13,6 +13,6 @@ SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" "$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ logic/library.c logic/reader.c \ - "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o tuku -ls -la tuku -file tuku + "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser +ls -la picbrowser +file picbrowser diff --git a/tuku/pack_tuku.sh b/tuku/pack_tuku.sh index b41b8dc..0df32ef 100755 --- a/tuku/pack_tuku.sh +++ b/tuku/pack_tuku.sh @@ -1,14 +1,14 @@ #!/bin/bash -# pack_tuku.sh — 出包 dev.tuku.app.mpk(tar.gz,根目录 dev.tuku.app/,含 app.json + tuku)。 -# .mpk 文件名茎 = app.json.package = 安装目录名(dev.tuku.app)。 +# pack_tuku.sh — 出包 os.meow.picbrowser.mpk(tar.gz,根目录 os.meow.picbrowser/,含 app.json + picbrowser)。 +# .mpk 文件名茎 = app.json.package = 安装目录名(os.meow.picbrowser)。 set -e cd "$(dirname "$0")" -[ -x tuku ] || ./build_tuku.sh +[ -x picbrowser ] || ./build_tuku.sh -rm -rf build/dev.tuku.app -mkdir -p build/dev.tuku.app -cp app.json tuku build/dev.tuku.app/ +rm -rf build/os.meow.picbrowser +mkdir -p build/os.meow.picbrowser +cp app.json picbrowser build/os.meow.picbrowser/ -rm -f dev.tuku.app.mpk -( cd build && tar czf ../dev.tuku.app.mpk dev.tuku.app ) -ls -la dev.tuku.app.mpk +rm -f os.meow.picbrowser.mpk +( cd build && tar czf ../os.meow.picbrowser.mpk os.meow.picbrowser ) +ls -la os.meow.picbrowser.mpk From 432875d4e673d594f5e8e862761bc8ab5877c7dc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:02:10 +0800 Subject: [PATCH 04/38] =?UTF-8?q?sdk:=20=E5=AE=BF=E4=B8=BB=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A4=B4=E6=B3=A8=E9=87=8A=E6=8C=89=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2=20+=20select=20T1=20=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_meow_ui_select: T1 选中高亮探针移到容器卡内(x=12), 适配 M6.37 起的 listview 默认容器模式;6/6 PASS - 各 test_*.c 头注释补 -Iinclude、meow_ui_nav.c 及精简源列表最小补集 (theme/widgets/notif_gesture/overlay/suppress/sdk_fixes/pinyin/ime_fix) - test_sb_statusbar 头注释给出可链接命令(--allow-multiple-definition) - notify_post/ui_audio_host/p1p2p3 标注需主仓组件,公开仓跳过 - docs/APP_DEV_GUIDE.md: 去掉叠字与过强表述,改为 34/38 实测口径 --- docs/APP_DEV_GUIDE.md | 2 +- sdk/test_ime_fix.c | 5 +++-- sdk/test_meow_cachegov.c | 4 ++-- sdk/test_meow_img_color.c | 4 ++-- sdk/test_meow_lyric.c | 2 +- sdk/test_meow_lyric_view.c | 4 ++-- sdk/test_meow_md3.c | 4 ++-- sdk/test_meow_ui_anim.c | 4 ++-- sdk/test_meow_ui_box.c | 4 ++-- sdk/test_meow_ui_config.c | 2 +- sdk/test_meow_ui_dispatch.c | 4 ++-- sdk/test_meow_ui_home_gesture.c | 2 +- sdk/test_meow_ui_iconbtn.c | 4 ++-- sdk/test_meow_ui_lazygrid.c | 4 ++-- sdk/test_meow_ui_listvirt.c | 4 ++-- sdk/test_meow_ui_scroll.c | 4 ++-- sdk/test_meow_ui_select.c | 6 +++--- sdk/test_meow_ui_str.c | 2 +- sdk/test_meow_ui_swipe.c | 4 ++-- sdk/test_meow_ui_theme.c | 5 +++-- sdk/test_meow_ui_theme_export.c | 4 ++-- sdk/test_meow_ui_view_event.c | 4 ++-- sdk/test_meow_ui_vol.c | 2 +- sdk/test_meow_ui_widgets.c | 5 +++-- sdk/test_net_ipc.c | 3 ++- sdk/test_notif_gesture.c | 5 +++-- sdk/test_notif_open.c | 2 +- sdk/test_notify_integration.c | 2 +- sdk/test_notify_ipc.c | 2 +- sdk/test_notify_post.c | 4 ++-- sdk/test_overlay_suppress.c | 5 +++-- sdk/test_p1p2p3_repro.c | 7 ++++--- sdk/test_pinyin.c | 4 ++-- sdk/test_sb_collapse.c | 2 +- sdk/test_sb_statusbar.c | 7 ++++--- sdk/test_sdk_fixes.c | 5 +++-- sdk/test_sdk_input_keys.c | 2 +- sdk/test_ui_audio_host.c | 3 ++- sdk/test_umbrella.c | 2 +- 39 files changed, 77 insertions(+), 67 deletions(-) diff --git a/docs/APP_DEV_GUIDE.md b/docs/APP_DEV_GUIDE.md index 592bdd8..40e1634 100644 --- a/docs/APP_DEV_GUIDE.md +++ b/docs/APP_DEV_GUIDE.md @@ -59,7 +59,7 @@ MEOW_SYSROOT="$HOME/Project/R3ProII/.tools/mipsel-linux-muslsf-cross/mipsel-linu SDK 依赖板载 `/dev/fb0` 与字库,宿主机没有——但你可以**链真实 SDK 目标文件 + 打桩**来跑逻辑单测(参考 `sdk/test_host_stub.c`): - `test_host_stub.c` 提供了 `meow_font_*` / `meow_ui_global_render` 等宿主桩(无 fb → 5×7 点阵回退;无字库 → `meow_font_load` 返回 -1)。 -- 宿主可 `gcc` 直接编译 SDK 各 `.c` + 你的逻辑,跑纯 C 断言,**不碰硬件**(`hello` 的 `test_p1p2p3_repro` / `test_meow_ui_widgets` / `test_pinyin` 等都是这么跑的,宿主宿主验证全 PASS)。 +- 宿主可 `gcc` 直接编译 SDK 各 `.c` + 你的逻辑,跑纯 C 断言,**不碰硬件**(`hello` 的 `test_p1p2p3_repro` / `test_meow_ui_widgets` / `test_pinyin` 等都是这么跑的)。注意:公开仓 38 个 `sdk/test_*.c` 的头注释已按实测修订——按注释补齐 `-Iinclude`、`meow_ui_nav.c` 等源集后 **34/38 全部 PASS**;`test_notify_post` / `test_ui_audio_host` / `test_p1p2p3_repro` 依赖主仓组件(launcher/audiod/hello_app),公开仓跳过;`test_net_ipc` 需运行中的 netmgrd,属手工验证项。宿主 `/tmp` 需可写(Termux 本机可用 `proot -b :/tmp` 提供)。 - 注意:`logic/` 层要写**纯 C**(不依赖 `meow_ui.h`),才能裸宿主单测;`ui/` 层才碰 SDK。 ### 1.3 qemu-mipsel 全系统仿真 diff --git a/sdk/test_ime_fix.c b/sdk/test_ime_fix.c index fb321e0..e2b6627 100644 --- a/sdk/test_ime_fix.c +++ b/sdk/test_ime_fix.c @@ -8,8 +8,9 @@ * gcc -Wall -Wextra -o test_ime_fix test_ime_fix.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ - * meow_ui_draw.c meow_ui_view.c meow_ui_img.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_media.c \ + * meow_ui_img.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * (meow_ui_widget.c 新 W_LISTVIEW 引用 meow_ui_img_scaled → 需链 meow_ui_img.c+stb_image.c+-lm) */ #include "meow_ui.h" diff --git a/sdk/test_meow_cachegov.c b/sdk/test_meow_cachegov.c index e516bc3..015b084 100644 --- a/sdk/test_meow_cachegov.c +++ b/sdk/test_meow_cachegov.c @@ -8,8 +8,8 @@ * - MEOW_CACHEGOV_C_FILE 指向临时文件写入 fake C 状态,断言 download_paused/prefetch_paused。 * * 编译(patch_v2/sdk 目录下): - * gcc -Os -Wall -Wextra -I. -Iinclude -I../meow_launcherd -c meow_cachegov.c -o /tmp/cg.o - * gcc -Os -Wall -Wextra -I. -Iinclude -I../meow_launcherd test_meow_cachegov.c /tmp/cg.o -lpthread -o test_meow_cachegov + * gcc -Os -Wall -Wextra -I. -Iinclude -c meow_cachegov.c -o /tmp/cg.o + * gcc -Os -Wall -Wextra -I. -Iinclude test_meow_cachegov.c /tmp/cg.o -lpthread -o test_meow_cachegov * 运行:./test_meow_cachegov (exit 0=PASS / 1=FAIL) */ #define _GNU_SOURCE diff --git a/sdk/test_meow_img_color.c b/sdk/test_meow_img_color.c index 433826c..0f23048 100644 --- a/sdk/test_meow_img_color.c +++ b/sdk/test_meow_img_color.c @@ -16,8 +16,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_img_color */ #include "meow_ui.h" diff --git a/sdk/test_meow_lyric.c b/sdk/test_meow_lyric.c index 0f44630..cb035cd 100644 --- a/sdk/test_meow_lyric.c +++ b/sdk/test_meow_lyric.c @@ -2,7 +2,7 @@ * test_meow_lyric.c — meow_lyric_* 宿主单测(2026-09-03,SDK 通用歌词组件批1)。 * * 编译(宿主,meow_lyric 为纯 libc + pthread,无需 SDK 其它对象): - * gcc -Wall -Wextra -pthread -o test_meow_lyric test_meow_lyric.c meow_lyric.c -I. + * gcc -Wall -Wextra -pthread -o test_meow_lyric test_meow_lyric.c meow_lyric.c -I. -Iinclude * ./test_meow_lyric * * 覆盖矩阵: diff --git a/sdk/test_meow_lyric_view.c b/sdk/test_meow_lyric_view.c index 5be898d..54873b9 100644 --- a/sdk/test_meow_lyric_view.c +++ b/sdk/test_meow_lyric_view.c @@ -8,8 +8,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_lyric_view * * 覆盖矩阵(对齐任务批2:活动行纯白/灰阶、翻译行占位不叠行、偏上锚定、折行中西文、点行 seek 命中、 diff --git a/sdk/test_meow_md3.c b/sdk/test_meow_md3.c index 918964e..b132ed9 100644 --- a/sdk/test_meow_md3.c +++ b/sdk/test_meow_md3.c @@ -9,8 +9,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_md3 */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_anim.c b/sdk/test_meow_ui_anim.c index 9ac1720..063b834 100644 --- a/sdk/test_meow_ui_anim.c +++ b/sdk/test_meow_ui_anim.c @@ -7,8 +7,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_anim */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_box.c b/sdk/test_meow_ui_box.c index 2ec9aaa..12eb6cf 100644 --- a/sdk/test_meow_ui_box.c +++ b/sdk/test_meow_ui_box.c @@ -7,8 +7,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_box */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_config.c b/sdk/test_meow_ui_config.c index ff6b733..8722c43 100644 --- a/sdk/test_meow_ui_config.c +++ b/sdk/test_meow_ui_config.c @@ -2,7 +2,7 @@ * test_meow_ui_config.c — meow_config_* 宿主单测(2026-08-30)。 * * 编译(宿主,config 为纯 libc,无需 SDK 其它对象): - * gcc -Wall -Wextra test_meow_ui_config.c meow_ui_config.c -o test_meow_ui_config -lpthread + * gcc -Wall -Wextra -I. -Iinclude test_meow_ui_config.c meow_ui_config.c -o test_meow_ui_config -lpthread * * 两种模式(meow_config_dir 经 pthread_once 缓存,目录只在首次调用确定,故分两次跑): * ./test_meow_ui_config env → 设 MEOW_DATA_DIR=,验证持久目录读写 diff --git a/sdk/test_meow_ui_dispatch.c b/sdk/test_meow_ui_dispatch.c index aaea24a..aa1c394 100644 --- a/sdk/test_meow_ui_dispatch.c +++ b/sdk/test_meow_ui_dispatch.c @@ -16,8 +16,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_dispatch (无设备,纯宿主) */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_home_gesture.c b/sdk/test_meow_ui_home_gesture.c index 7bc97aa..f754cc6 100644 --- a/sdk/test_meow_ui_home_gesture.c +++ b/sdk/test_meow_ui_home_gesture.c @@ -20,7 +20,7 @@ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_home_gesture */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_iconbtn.c b/sdk/test_meow_ui_iconbtn.c index 6da9656..206f148 100644 --- a/sdk/test_meow_ui_iconbtn.c +++ b/sdk/test_meow_ui_iconbtn.c @@ -12,8 +12,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_iconbtn */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_lazygrid.c b/sdk/test_meow_ui_lazygrid.c index 806bc67..6d9636f 100644 --- a/sdk/test_meow_ui_lazygrid.c +++ b/sdk/test_meow_ui_lazygrid.c @@ -11,8 +11,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_lazygrid.c meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_lazygrid.c meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_lazygrid */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_listvirt.c b/sdk/test_meow_ui_listvirt.c index f7844c0..2fd9c58 100644 --- a/sdk/test_meow_ui_listvirt.c +++ b/sdk/test_meow_ui_listvirt.c @@ -13,8 +13,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_listvirt */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_scroll.c b/sdk/test_meow_ui_scroll.c index 9aef33f..9f5cfa8 100644 --- a/sdk/test_meow_ui_scroll.c +++ b/sdk/test_meow_ui_scroll.c @@ -7,8 +7,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_scroll */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_select.c b/sdk/test_meow_ui_select.c index 3d6f0fd..fbad471 100644 --- a/sdk/test_meow_ui_select.c +++ b/sdk/test_meow_ui_select.c @@ -9,8 +9,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_select.c meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_select.c meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_select */ #include "meow_ui.h" @@ -114,7 +114,7 @@ static void test_select_highlight(void) /* 单选高亮:cur=2(第 3 行)选中背景 = primaryContainer */ const meow_theme *thm = meow_theme_get(); int pc_r, pc_g, pc_b; UNPK(thm->primaryContainer, pc_r, pc_g, pc_b); - int hx = 6, hy = list_y + 2 * SEL_ROW + SEL_ROW / 2; + int hx = 12, hy = list_y + 2 * SEL_ROW + SEL_ROW / 2; /* 容器卡横向内缩 8px(MEOW_LISTV_CARD_MARGIN),探针取卡内 */ int r, g, b; rd_px(hx, hy, &r, &g, &b); CHECK(near2(r, pc_r) && near2(g, pc_g) && near2(b, pc_b), "T1 highlight row2 primaryContainer got %02X%02X%02X", r, g, b); diff --git a/sdk/test_meow_ui_str.c b/sdk/test_meow_ui_str.c index 6932331..4ae343d 100644 --- a/sdk/test_meow_ui_str.c +++ b/sdk/test_meow_ui_str.c @@ -2,7 +2,7 @@ * test_meow_ui_str.c — meow_str_* 宿主单测(2026-09-02,§13.i18n 雏形)。 * * 编译(宿主,meow_ui_str 为纯 libc,无需 SDK 其它对象): - * gcc -Wall -Wextra -o test_meow_ui_str test_meow_ui_str.c meow_ui_str.c -I. + * gcc -Wall -Wextra -o test_meow_ui_str test_meow_ui_str.c meow_ui_str.c -I. -Iinclude * * 两种模式(覆盖表按 locale 懒加载,分两次跑): * ./test_meow_ui_str tmp → unsetenv MEOW_DATA_DIR,仅验证内嵌双语表(无覆盖表) diff --git a/sdk/test_meow_ui_swipe.c b/sdk/test_meow_ui_swipe.c index d40787b..b81dbf5 100644 --- a/sdk/test_meow_ui_swipe.c +++ b/sdk/test_meow_ui_swipe.c @@ -11,8 +11,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_swipe */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_theme.c b/sdk/test_meow_ui_theme.c index b9acdeb..9827564 100644 --- a/sdk/test_meow_ui_theme.c +++ b/sdk/test_meow_ui_theme.c @@ -10,8 +10,9 @@ * gcc -o test_meow_ui_theme test_meow_ui_theme.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ - * meow_ui_draw.c meow_ui_view.c meow_ui_img.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_img.c meow_ui_media.c \ + * meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * * 验证要点:四套主题 primary/radius 各不相同(MD3 蓝 / MIUIx 橙 + 更大圆角), * 同 style 下 light/dark 的 surface 成对切换;图形用 MEOW_UI_DUMP 导出 PPM 目检。 diff --git a/sdk/test_meow_ui_theme_export.c b/sdk/test_meow_ui_theme_export.c index 6260499..99d6317 100644 --- a/sdk/test_meow_ui_theme_export.c +++ b/sdk/test_meow_ui_theme_export.c @@ -14,8 +14,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_theme_export */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_view_event.c b/sdk/test_meow_ui_view_event.c index 42c1a2e..62e5b1e 100644 --- a/sdk/test_meow_ui_view_event.c +++ b/sdk/test_meow_ui_view_event.c @@ -11,8 +11,8 @@ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_meow_ui_view_event */ #include "meow_ui.h" diff --git a/sdk/test_meow_ui_vol.c b/sdk/test_meow_ui_vol.c index cf1d7a3..3ca6c45 100644 --- a/sdk/test_meow_ui_vol.c +++ b/sdk/test_meow_ui_vol.c @@ -12,7 +12,7 @@ * 当前夹后音量(含 step 语义:a.volume_step 只决定步进,展示原样不/舍入)。 * * 编译(宿主 gcc,mipsel 无关): - * gcc -o test_meow_ui_vol test_meow_ui_vol.c meow_ui_vol.c -I. + * gcc -o test_meow_ui_vol test_meow_ui_vol.c meow_ui_vol.c -I. -Iinclude */ #include "meow_ui_vol.h" diff --git a/sdk/test_meow_ui_widgets.c b/sdk/test_meow_ui_widgets.c index d2005d1..ede68c0 100644 --- a/sdk/test_meow_ui_widgets.c +++ b/sdk/test_meow_ui_widgets.c @@ -5,8 +5,9 @@ * gcc -o test_meow_ui_widgets test_meow_ui_widgets.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ - * meow_ui_draw.c meow_ui_view.c meow_ui_img.c meow_notify_ipc.c stb_image.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_img.c meow_ui_media.c \ + * meow_notify_ipc.c stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:MEOW_UI_DUMP=/tmp/b2.ppm ./test_meow_ui_widgets (可选,dump 屏幕) * * 注:W_LISTVIEW 的 MEOW_LIST_ICON_IMG 分支调用 meow_ui_img_scaled → 宿主链 meow_ui_img.c+stb_image.c+-lm。 diff --git a/sdk/test_net_ipc.c b/sdk/test_net_ipc.c index a15f6a7..d31b30c 100644 --- a/sdk/test_net_ipc.c +++ b/sdk/test_net_ipc.c @@ -4,8 +4,9 @@ * (MEOW_NET_SOCK 指向测试 socket)验证: * - meow_net_policy_check(host):按 MEOW_LOG_APP 实体判定 allow/block(netmgrd 决策权威); * - meow_net_report(rx,tx):best-effort 上报(netmgrd 记入 entity_bytes)。 - * 用法:cc -Iinclude -I. -I../meow_launcherd test_net_ipc.c meow_net_ipc.c -o test_net_ipc + * 用法:cc -Iinclude -I. test_net_ipc.c meow_net_ipc.c -o test_net_ipc * 然后 MEOW_NET_SOCK= MEOW_LOG_APP= ./test_net_ipc + * 注:需运行中的 netmgrd;缺 netmgrd 时本测试无断言恒退 0,不能作为通过证据。 */ #include "include/meow_ui_net.h" #include diff --git a/sdk/test_notif_gesture.c b/sdk/test_notif_gesture.c index 4c5db97..979743c 100644 --- a/sdk/test_notif_gesture.c +++ b/sdk/test_notif_gesture.c @@ -16,8 +16,9 @@ * 编译(宿主 gcc,链真实 gfx/sys/theme/vol/log/widget/input/ime/media/notify_ipc + 打桩 font/global): * gcc -Wall -Wextra -o test_notif_gesture test_notif_gesture.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ - * meow_ui_widget.c meow_ui_input.c meow_ui_ime.c meow_ui_str.c meow_ui_media.c meow_notify_ipc.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_widget.c meow_ui_input.c meow_ui_ime.c meow_ui_str.c meow_ui_activity.c meow_ui_media.c \ + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_notify_ipc.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_notif_gesture */ #include "meow_ui.h" diff --git a/sdk/test_notif_open.c b/sdk/test_notif_open.c index 394d34f..1f4f614 100644 --- a/sdk/test_notif_open.c +++ b/sdk/test_notif_open.c @@ -10,7 +10,7 @@ * gcc -Wall -o test_notif_open test_notif_open.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_notify_ipc.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm * 运行:./test_notif_open */ #include "meow_ui.h" diff --git a/sdk/test_notify_integration.c b/sdk/test_notify_integration.c index 2f50f0a..865e274 100644 --- a/sdk/test_notify_integration.c +++ b/sdk/test_notify_integration.c @@ -13,7 +13,7 @@ * * 编译(宿主 gcc): * gcc -o test_notify_integration test_notify_integration.c \ - * meow_ui_notify.c meow_notify_ipc.c -I. -I../meow_launcherd -lpthread + * meow_ui_notify.c meow_notify_ipc.c -I. -Iinclude -lpthread */ #include "meow_ui.h" #include "meow_notify_ipc.h" diff --git a/sdk/test_notify_ipc.c b/sdk/test_notify_ipc.c index c7f5dc3..760c309 100644 --- a/sdk/test_notify_ipc.c +++ b/sdk/test_notify_ipc.c @@ -11,7 +11,7 @@ * * 编译(宿主 gcc,不依赖 meow_ui.a 其余 TU): * gcc -o test_notify_ipc test_notify_ipc.c meow_notify_ipc.c \ - * -I. -I../meow_launcherd -lpthread + * -I. -Iinclude -lpthread * 运行:./test_notify_ipc (全部 PASS 退出码 0,任一 FAIL 退出码 1) */ #include "meow_notify_ipc.h" diff --git a/sdk/test_notify_post.c b/sdk/test_notify_post.c index b3d0440..b73b7b2 100644 --- a/sdk/test_notify_post.c +++ b/sdk/test_notify_post.c @@ -8,9 +8,9 @@ * 验证:SDK meow_notify_ipc_send() 产出的帧被 meow_notif_post() 真实落库并回分配 nid>0; * 随后 meow_notify_ipc_dismiss() 经 meow_notif_dismiss() 撤下(count 归 0)。 * - * 编译(宿主 gcc): + * 编译(宿主 gcc;**需主仓组件,公开仓跳过**——../meow_launcherd/meow_notif.c 未随本仓发布): * gcc -o test_notify_post test_notify_post.c meow_notify_ipc.c \ - * ../meow_launcherd/meow_notif.c -I. -I../meow_launcherd -lpthread + * ../meow_launcherd/meow_notif.c -I. -Iinclude -I../meow_launcherd -lpthread */ #include "meow_notify_ipc.h" #include "meow_ui.h" diff --git a/sdk/test_overlay_suppress.c b/sdk/test_overlay_suppress.c index d0f7f54..f0763c1 100644 --- a/sdk/test_overlay_suppress.c +++ b/sdk/test_overlay_suppress.c @@ -19,8 +19,9 @@ * gcc -Wall -Wextra -o test_overlay_suppress test_overlay_suppress.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ - * meow_ui_draw.c meow_ui_view.c meow_notify_ipc.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_media.c \ + * meow_notify_ipc.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread */ #include "meow_ui.h" #include "meow_ui_internal.h" diff --git a/sdk/test_p1p2p3_repro.c b/sdk/test_p1p2p3_repro.c index 474480b..b6ee0a5 100644 --- a/sdk/test_p1p2p3_repro.c +++ b/sdk/test_p1p2p3_repro.c @@ -15,11 +15,12 @@ * P2 list 焦点:命中非 list 区域时释放列表焦点、切页滚动(不再沿用旧焦点继续滚列表)。 * P3 list 脏区:W_LIST 绘制已裁剪到视口;滚动(非整行偏移)+选中行不再向标题间隙/底缘下泄漏。 * - * 编译(宿主 gcc,链真实 gfx/sys/theme/vol/log/widget/input/activity/dialog/ime/img/config/ - * notify/ipc + 打桩 font/global(test_host_stub) + stb_image + hello 拆分层): + * 编译(需主仓组件,公开仓跳过):本仓不含 ../hello_app(公开仓为 examples/hello),且本文件 + * 直接 #include "../hello_app/hello_internal.h";以下路径按主仓布局(打桩 font/global + + * stb_image + hello 拆分层): * 1) 单独编译 app.c 并把 main 改名 hello_app_main(否则与本文件 main 冲突): * gcc -c -Wall -Wextra -DMEOW_UI_HOST_TEST -Dmain=hello_app_main \ - * -I. -I../hello_app ../hello_app/app.c -o app.o + * -I. -Iinclude -I../hello_app ../hello_app/app.c -o app.o * 2) 编译本文件 + 其余 hello 模块 + SDK 源 + 打桩,链接 app.o: * gcc -Wall -Wextra -DMEOW_UI_HOST_TEST -o test_p1p2p3_repro test_p1p2p3_repro.c app.o \ * test_host_stub.c meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ diff --git a/sdk/test_pinyin.c b/sdk/test_pinyin.c index 6b9a764..b594ac7 100644 --- a/sdk/test_pinyin.c +++ b/sdk/test_pinyin.c @@ -10,8 +10,8 @@ * gcc -Wall -Wextra -o test_pinyin test_pinyin.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ - * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_img.c meow_ui_media.c \ - * meow_notify_ipc.c stb_image.c -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_img.c meow_ui_media.c \ + * meow_notify_ipc.c stb_image.c meow_ui_nav.c -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm */ #include "meow_ui.h" #include "meow_ui_internal.h" diff --git a/sdk/test_sb_collapse.c b/sdk/test_sb_collapse.c index 3967769..cf97463 100644 --- a/sdk/test_sb_collapse.c +++ b/sdk/test_sb_collapse.c @@ -10,7 +10,7 @@ * 编译(宿主 gcc,链真实 gfx/sys/theme/vol/log + 打桩 font/global + meow_notify_ipc): * gcc -o test_sb_collapse test_sb_collapse.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c meow_notify_ipc.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread */ #include "meow_ui.h" #include "meow_ui_internal.h" diff --git a/sdk/test_sb_statusbar.c b/sdk/test_sb_statusbar.c index ca3af2b..a370563 100644 --- a/sdk/test_sb_statusbar.c +++ b/sdk/test_sb_statusbar.c @@ -6,9 +6,10 @@ * 3. 图标簇+值:fed 数据 → render 依次调用 meow_gfx_blit_icon(电量/音量/蓝牙/WiFi/亮度) * 与 meow_ui_text_right(%n 值);WiFi 已连-50dBm → WIFI_STRONG。 * - * 编译(宿主 gcc): - * gcc -o test_sb_statusbar test_sb_statusbar.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c \ - * -I. -I../meow_launcherd + * 编译(宿主 gcc;测试自带桩与 meow_ui_gfx/meow_ui_log 的符号重复,需 --allow-multiple-definition): + * gcc -Wall -Wextra -o test_sb_statusbar test_sb_statusbar.c test_host_stub.c \ + * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c meow_notify_ipc.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm -Wl,--allow-multiple-definition */ #include "meow_ui.h" #include "meow_ui_internal.h" diff --git a/sdk/test_sdk_fixes.c b/sdk/test_sdk_fixes.c index 1134668..4ed7506 100644 --- a/sdk/test_sdk_fixes.c +++ b/sdk/test_sdk_fixes.c @@ -10,8 +10,9 @@ * gcc -Wall -o test_sdk_fixes test_sdk_fixes.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ - * meow_ui_draw.c meow_ui_view.c meow_notify_ipc.c \ - * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread + * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_media.c \ + * meow_notify_ipc.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread */ #include "meow_ui.h" #include "meow_ui_internal.h" diff --git a/sdk/test_sdk_input_keys.c b/sdk/test_sdk_input_keys.c index 8a5dfb1..44e7449 100644 --- a/sdk/test_sdk_input_keys.c +++ b/sdk/test_sdk_input_keys.c @@ -12,7 +12,7 @@ * * 编译(宿主 gcc,仅链 vol/media 两个 TU;无需 meow_ui 其它子系统): * gcc -Wall -Wextra -pthread -o test_sdk_input_keys test_sdk_input_keys.c \ - * meow_ui_vol.c meow_ui_media.c -I. -I../meow_launcherd + * meow_ui_vol.c meow_ui_media.c -I. -Iinclude */ #include "meow_ui_vol.h" #include "meow_ui_media.h" diff --git a/sdk/test_ui_audio_host.c b/sdk/test_ui_audio_host.c index 70a2789..f0021ad 100644 --- a/sdk/test_ui_audio_host.c +++ b/sdk/test_ui_audio_host.c @@ -7,7 +7,8 @@ * - on_preempt 回调:第二客户端(raw)抢先 → 本 SDK 客户端收到 PREEMPTED,write 返回负值 * * 启动被测试 audiod(宿主二进制,FILE sink)。 - * 编译:cc -I../meow_launcherd -I. -Os -Wall -Wextra test_ui_audio_host.c meow_ui_audio.c -lpthread -o test_ui_audio_host + * **需主仓组件,公开仓跳过**:运行需 ../meow_audiod/meow_audiod_host(未随本仓发布)。 + * 编译:cc -I. -Iinclude -Os -Wall -Wextra test_ui_audio_host.c meow_ui_audio.c -lpthread -lm -o test_ui_audio_host */ #include "meow_ui.h" #include "meow_ipc.h" diff --git a/sdk/test_umbrella.c b/sdk/test_umbrella.c index efd8c10..7460d8d 100644 --- a/sdk/test_umbrella.c +++ b/sdk/test_umbrella.c @@ -1,7 +1,7 @@ /* * test_umbrella.c — 伞头完整性回归:只 #include "meow_ui.h",抽查各模块代表性声明 * (类型 / 函数 / 宏)都存在。头拆分后用来证明伞头聚合无遗漏、签名未变。 - * 用法:gcc -std=c11 -Wall -Wextra -I -c test_umbrella.c -o test_umbrella.o + * 用法(在 sdk/ 目录下):gcc -std=c11 -Wall -Wextra -I. -Iinclude -c test_umbrella.c -o test_umbrella.o * (纯声明级检查,不链接、不调用;产生可执行代码仍 0 warning)。 */ #include "meow_ui.h" From 23c9db73df840dd4e4d97fa8cc37a1f9f6617bd0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:44:03 +0800 Subject: [PATCH 05/38] =?UTF-8?q?feat(tuku):=20=E9=98=85=E8=AF=BB=E5=99=A8?= =?UTF-8?q?=E5=B7=A6=E5=8F=B3=E7=BF=BB=E9=A1=B5=E6=A0=B8=E5=BF=83=E2=80=94?= =?UTF-8?q?=E2=80=94=E9=A1=B5=E6=BA=90=E5=A5=91=E7=BA=A6/=E9=A2=84?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E7=AA=97=E5=8F=A3/=E8=B7=9F=E6=89=8B?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5/=E6=96=B9=E5=90=91=E6=84=9F=E7=9F=A5=20tap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页源契约(logic/reader):count/meta/open/close + 目录册(文件夹即书)为第一个实现; meta 由纯 C 图片头探测(PNG IHDR/JPEG SOF/BMP)提供原始尺寸,条漫可先排版再取图, zip 册(M9/M11)只需另写 mr_book_ops,两种消费方式零改动复用 - 翻页引擎:pthread 后台解码窗口=当前±1 页(目录册 downscale 到屏幕长边),翻页只 blit 缓存页图 + meow_anim 150ms 滑动吸附;未命中立即切页显示加载态、不在翻页路径解码; 只 flush 内容区;decode/flip 首帧/翻页完成/进出内存全部 meow_log 打点 - 阅读页 UI:fit 三档(适宽/适高/原始,TopAppBar 动作循环,标题显示页码 x/y)+ 双击以点击点为锚放大(临时缓冲超上限自动降倍率)+ 单指拖拽/滑动步进平移; tap 三分区方向感知(RTL 默认:左=下一页,LTR 反之),中区切 chrome; 左缘返回交给 app.c、屏底热区交给 SDK 回桌面,两者都不拦截 - 跟手:app.c 仅补 2 处极小路由(ACT_READER 的 down/move),reader 自行算位移; 屏底 28px/左缘 24px 不进拖动,竖向主导放弃横滑 - 内存纪律:驻留 ≤3 张 downscale 页图;放大时窗口收缩到当前页;on_leave 停解码线程、 释放全部页图/册索引/TopAppBar,并记录 VmRSS/MemAvailable 前后对比 - 测试/构建:新增 test_reader_core(契约边界/空册/越界/重复 open-close/首末页/头探测/ 方向语义/fit/平移夹取,108 项);run_tests.sh 与 build_tuku.sh 源列表接线; 宿主 46+108 全 PASS,交叉构建静态 MIPS32r2 ELF、-Wall -Wextra 零警告 --- tuku/app.c | 5 + tuku/build_tuku.sh | 2 +- tuku/logic/reader/nav.c | 120 ++++ tuku/logic/reader/nav.h | 70 ++ tuku/logic/reader/page_source.c | 282 ++++++++ tuku/logic/reader/page_source.h | 91 +++ tuku/test/run_tests.sh | 5 + tuku/test/test_reader_core.c | 452 +++++++++++++ tuku/ui/reader/reader.c | 1111 +++++++++++++++++++++++++++++-- tuku/ui/reader/reader.h | 19 + 10 files changed, 2088 insertions(+), 69 deletions(-) create mode 100644 tuku/logic/reader/nav.c create mode 100644 tuku/logic/reader/nav.h create mode 100644 tuku/logic/reader/page_source.c create mode 100644 tuku/logic/reader/page_source.h create mode 100644 tuku/test/test_reader_core.c create mode 100644 tuku/ui/reader/reader.h diff --git a/tuku/app.c b/tuku/app.c index 7f380ca..893a0d5 100644 --- a/tuku/app.c +++ b/tuku/app.c @@ -10,6 +10,7 @@ * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 */ #include "tuku_internal.h" +#include "ui/reader/reader.h" #include "meow_ui_nav.h" #include @@ -89,14 +90,18 @@ static void app_on_swipe_progress(int x_start, int dx) meow_ui_swipe_hint(x_start + dx); } +/* 跟手拖动路由(M6):ACT_READER 需要原始 down/move 算位移;其余页保持原语义。 + * 屏底热区/左缘的避让由 reader 侧按同一口径判断(reader_on_touch_down)。 */ static void app_on_touch_down(int x, int y) { if (app_active() == ACT_LIBRARY) library_on_touch_down(x, y); + else if (app_active() == ACT_READER) reader_on_touch_down(x, y); } static void app_on_touch_move(int x, int y) { if (app_active() == ACT_LIBRARY) library_on_touch_move(x, y); + else if (app_active() == ACT_READER) reader_on_touch_move(x, y); } int main(void) diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index 43dc07f..bccc665 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -12,7 +12,7 @@ SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" "$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ - logic/library.c logic/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser ls -la picbrowser file picbrowser diff --git a/tuku/logic/reader/nav.c b/tuku/logic/reader/nav.c new file mode 100644 index 0000000..fb7d29d --- /dev/null +++ b/tuku/logic/reader/nav.c @@ -0,0 +1,120 @@ +/* logic/reader/nav.c — 阅读导航纯逻辑实现(见 nav.h 的方向语义说明)。 */ +#include "logic/reader/nav.h" + +int mr_nav_tap_zone(int x, int view_w) +{ + if (view_w <= 0) return MR_ZONE_MIDDLE; + if (x < view_w / 3) return MR_ZONE_LEFT; + if (x >= (view_w * 2) / 3) return MR_ZONE_RIGHT; + return MR_ZONE_MIDDLE; +} + +int mr_nav_tap_delta(int rtl, int zone) +{ + if (zone == MR_ZONE_MIDDLE) return 0; + if (rtl) return (zone == MR_ZONE_LEFT) ? 1 : -1; /* RTL:左进右退 */ + return (zone == MR_ZONE_LEFT) ? -1 : 1; /* LTR:右进左退 */ +} + +int mr_nav_swipe_delta(int rtl, int dx) +{ + if (dx == 0) return 0; + if (rtl) return dx > 0 ? 1 : -1; + return dx > 0 ? -1 : 1; +} + +int mr_nav_clamp_page(int page, int count) +{ + if (count <= 0) return -1; + if (page < 0) return 0; + if (page >= count) return count - 1; + return page; +} + +int mr_nav_commit_dist(int view_w) +{ + int t = view_w / 4; + if (t < 60) t = 60; + if (t > 160) t = 160; + return t; +} + +int mr_nav_commit(int dx, int view_w) +{ + int adx = dx < 0 ? -dx : dx; + return adx >= mr_nav_commit_dist(view_w); +} + +int mr_nav_fit_size(int fit, int src_w, int src_h, int vw, int vh, + int *out_w, int *out_h) +{ + if (!out_w || !out_h || src_w <= 0 || src_h <= 0 || vw <= 0 || vh <= 0) return -1; + long long w, h; + if (fit == MR_FIT_ORIGINAL) { + w = src_w; + h = src_h; + } else if (fit == MR_FIT_HEIGHT) { + h = vh; + w = (long long)src_w * vh / src_h; + } else { + w = vw; + h = (long long)src_h * vw / src_w; + } + if (w < 1) w = 1; + if (h < 1) h = 1; + *out_w = (int)w; + *out_h = (int)h; + return 0; +} + +int mr_nav_zoom_size(int w, int h, int zoom_pct, int *out_w, int *out_h) +{ + if (!out_w || !out_h || w <= 0 || h <= 0 || zoom_pct <= 0) return -1; + long long ww = (long long)w * zoom_pct / 100; + long long hh = (long long)h * zoom_pct / 100; + if (ww < 1) ww = 1; + if (hh < 1) hh = 1; + *out_w = (int)ww; + *out_h = (int)hh; + return 0; +} + +void mr_nav_pan_limit(int content_w, int content_h, int vw, int vh, + int *lim_x, int *lim_y) +{ + int lx = content_w - vw; + int ly = content_h - vh; + if (lx < 0) lx = 0; + if (ly < 0) ly = 0; + /* 平移量是「中心偏移」:可左右各移动溢出量的一半,两边才都看得到。 */ + if (lim_x) *lim_x = lx / 2; + if (lim_y) *lim_y = ly / 2; +} + +void mr_nav_pan_clamp(int content_w, int content_h, int vw, int vh, + int *pan_x, int *pan_y) +{ + int lx, ly; + mr_nav_pan_limit(content_w, content_h, vw, vh, &lx, &ly); + if (pan_x) { + if (*pan_x > lx) *pan_x = lx; + if (*pan_x < -lx) *pan_x = -lx; + } + if (pan_y) { + if (*pan_y > ly) *pan_y = ly; + if (*pan_y < -ly) *pan_y = -ly; + } +} + +int mr_nav_slide_from(int rtl, int delta, int view_w) +{ + if (!delta) return 0; + int from = rtl ? -delta : delta; + return from * view_w; +} + +int mr_nav_fit_next(int fit) +{ + if (fit < 0 || fit >= MR_FIT_COUNT) return MR_FIT_WIDTH; + return (fit + 1) % MR_FIT_COUNT; +} diff --git a/tuku/logic/reader/nav.h b/tuku/logic/reader/nav.h new file mode 100644 index 0000000..4d328f3 --- /dev/null +++ b/tuku/logic/reader/nav.h @@ -0,0 +1,70 @@ +/* logic/reader/nav.h — 阅读导航纯逻辑(方向语义 / tap 分区 / fit / 平移 clamp)。 + * + * 纯 C、无 SDK 依赖:reader.c 与宿主测试共用同一实现(logic/reader/nav.c)。 + * + * 方向语义(mr_reader_get_direction,默认 RTL): + * - LTR(左→右读):页码沿屏幕向右递增;屏左 1/3 = 上一页,屏右 1/3 = 下一页; + * 手指向右拖露出左边那页 = 上一页。 + * - RTL(日漫,默认):页码沿屏幕向左递增;屏左 1/3 = **下一页**,屏右 1/3 = 上一页; + * 手指向右拖露出左边那页 = 下一页。 + * 图像本身不做镜像(RTL 只影响翻页语义)。 + */ +#ifndef MR_READER_NAV_H +#define MR_READER_NAV_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- fit 三档 ---- */ +#define MR_FIT_WIDTH 0 /* 适宽:撑满屏宽 */ +#define MR_FIT_HEIGHT 1 /* 适高:撑满屏高 */ +#define MR_FIT_ORIGINAL 2 /* 原始:1:1 像素 */ +#define MR_FIT_COUNT 3 + +/* ---- tap 分区(按屏幕横向三分) ---- */ +#define MR_ZONE_LEFT 0 +#define MR_ZONE_MIDDLE 1 +#define MR_ZONE_RIGHT 2 + +/* 屏宽三分:左/中/右。view_w<=0 返回 MIDDLE。 */ +int mr_nav_tap_zone(int x, int view_w); + +/* tap 分区的页增量:+1=下一页 / -1=上一页 / 0=chrome 显隐(中区)。 */ +int mr_nav_tap_delta(int rtl, int zone); + +/* 中部横向拖动的页增量。dx>0 = 手指向右(露出屏幕左侧那页)。 + * 首/末页不在这里判断——调用方用 mr_nav_clamp_page 落到实际目标页。 */ +int mr_nav_swipe_delta(int rtl, int dx); + +/* 页下标 clamp;count<=0 返回 -1。 */ +int mr_nav_clamp_page(int page, int count); + +/* 翻页提交阈值:视口宽的 1/4,夹在 [60,160] px。 */ +int mr_nav_commit_dist(int view_w); + +/* 松手位移是否达到翻页阈值。 */ +int mr_nav_commit(int dx, int view_w); + +/* fit 目标尺寸(未含 zoom)。0=成功;参数非法 -1。 */ +int mr_nav_fit_size(int fit, int src_w, int src_h, int vw, int vh, int *out_w, int *out_h); + +/* 按百分比放大(zoom_pct=100 原样;200=2×)。0=成功。 */ +int mr_nav_zoom_size(int w, int h, int zoom_pct, int *out_w, int *out_h); + +/* 平移半幅:内容比视口每侧多出的像素(>=0)。内容不溢出该轴 → 0(该轴不可平移)。 */ +void mr_nav_pan_limit(int content_w, int content_h, int vw, int vh, int *lim_x, int *lim_y); + +/* 把平移量夹到 ±半幅。 */ +void mr_nav_pan_clamp(int content_w, int content_h, int vw, int vh, int *pan_x, int *pan_y); + +/* 翻页动画的进场起点(px):下一页在 RTL 从左侧进、LTR 从右侧进。delta=0 返回 0。 */ +int mr_nav_slide_from(int rtl, int delta, int view_w); + +/* fit 档循环:适宽 → 适高 → 原始 → 适宽。 */ +int mr_nav_fit_next(int fit); + +#ifdef __cplusplus +} +#endif +#endif /* MR_READER_NAV_H */ diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c new file mode 100644 index 0000000..f33297d --- /dev/null +++ b/tuku/logic/reader/page_source.c @@ -0,0 +1,282 @@ +/* logic/reader/page_source.c — 页源契约实现(纯 C):目录册 + 图片头探测。 + * + * 契约与两种消费方式的说明见 page_source.h。本文件不含 SDK 依赖:解码/释放由 + * UI 层注回回调,因此可在宿主测试里用假回调覆盖「空册/越界/重复 open-close/首末页」。 + */ +#include "logic/reader/page_source.h" + +#include +#include +#include +#include +#include + +/* ============================================================ */ +/* 契约包装 */ +/* ============================================================ */ +int mr_book_valid(const mr_book *b) +{ + return b && b->ops && b->ops->count && b->ops->open; +} + +int mr_book_count(const mr_book *b) +{ + if (!mr_book_valid(b)) return 0; + int n = b->ops->count(b->ctx); + return n > 0 ? n : 0; +} + +int mr_book_meta(const mr_book *b, int index, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!mr_book_valid(b) || !b->ops->meta) return 0; + if (index < 0 || index >= mr_book_count(b)) return 0; + return b->ops->meta(b->ctx, index, w, h) ? 1 : 0; +} + +meow_img *mr_book_open(mr_book *b, int index) +{ + if (!mr_book_valid(b)) return NULL; + if (index < 0 || index >= mr_book_count(b)) return NULL; /* 越界:契约边界 */ + return b->ops->open(b->ctx, index); +} + +void mr_book_close(mr_book *b, meow_img *img) +{ + if (!img) return; + if (!mr_book_valid(b) || !b->ops->close) return; + b->ops->close(b->ctx, img); +} + +void mr_book_destroy(mr_book *b) +{ + if (!b) return; + if (b->ops && b->ops->destroy && b->ctx) b->ops->destroy(b->ctx); + b->ops = NULL; + b->ctx = NULL; +} + +/* ============================================================ */ +/* 目录册 */ +/* ============================================================ */ +static int name_cmp_qsort(const void *pa, const void *pb) +{ + const char *a = *(const char *const *)pa; + const char *b = *(const char *const *)pb; + return mr_natural_cmp(a, b); +} + +void mr_dir_book_path(const mr_dir_book *db, int index, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + out[0] = '\0'; + if (!db || index < 0 || index >= db->count || !db->names[index]) return; + size_t dl = strlen(db->dir); + if (dl > 0 && db->dir[dl - 1] == '/') + snprintf(out, (size_t)outsz, "%s%s", db->dir, db->names[index]); + else + snprintf(out, (size_t)outsz, "%s/%s", db->dir, db->names[index]); +} + +void mr_dir_book_free(mr_dir_book *db) +{ + if (!db) return; + for (int i = 0; i < db->count; i++) free(db->names[i]); + free(db->names); + db->names = NULL; + db->count = 0; +} + +int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud) +{ + if (!db) return -1; + memset(db, 0, sizeof *db); + if (!dir || !dir[0] || !load || !unload) return -1; + if (maxdim <= 0) maxdim = 720; + if (strlen(dir) >= sizeof db->dir) return -1; + + DIR *d = opendir(dir); + if (!d) return -1; + + snprintf(db->dir, sizeof db->dir, "%s", dir); + db->maxdim = maxdim; + db->load = load; + db->unload = unload; + db->ud = ud; + + int cap = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (name[0] == '.') continue; /* 隐藏文件不参与成册 */ + if (!mr_path_is_image(name)) continue; + + char full[MR_PATH_MAX]; + size_t dl = strlen(db->dir); + int n = (dl > 0 && db->dir[dl - 1] == '/') + ? snprintf(full, sizeof full, "%s%s", db->dir, name) + : snprintf(full, sizeof full, "%s/%s", db->dir, name); + if (n <= 0 || n >= (int)sizeof full) continue; + struct stat st; + if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; /* 跳过符号链接/目录 */ + + if (db->count >= MR_BOOK_MAX_PAGES) break; + if (db->count >= cap) { + int nc = cap ? cap * 2 : 32; + char **nn = (char **)realloc(db->names, (size_t)nc * sizeof *nn); + if (!nn) { mr_dir_book_free(db); closedir(d); return -1; } + db->names = nn; + cap = nc; + } + size_t len = strlen(name); + db->names[db->count] = (char *)malloc(len + 1); + if (!db->names[db->count]) { mr_dir_book_free(db); closedir(d); return -1; } + memcpy(db->names[db->count], name, len + 1); + db->count++; + } + closedir(d); + + if (db->count > 1) + qsort(db->names, (size_t)db->count, sizeof *db->names, name_cmp_qsort); + return db->count; +} + +/* ============================================================ */ +/* 图片头探测(不解码像素) */ +/* ============================================================ */ +static unsigned probe_be32(const unsigned char *p) +{ + return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3]; +} + +static int probe_png(FILE *f, int *w, int *h) +{ + unsigned char hdr[24]; + if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + static const unsigned char sig[8] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }; + if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0; + unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20); + if (ww == 0 || hh == 0) return 0; + *w = (int)ww; + *h = (int)hh; + return 1; +} + +static int probe_jpeg(FILE *f, int *w, int *h) +{ + unsigned char b[2]; + if (fread(b, 1, 2, f) != 2 || b[0] != 0xFF || b[1] != 0xD8) return 0; + for (;;) { + int c = fgetc(f); + if (c == EOF) return 0; + if (c != 0xFF) continue; + int m = fgetc(f); + while (m == 0xFF) m = fgetc(f); /* 填充字节 */ + if (m == EOF) return 0; + if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */ + if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */ + unsigned char lb[2]; + if (fread(lb, 1, 2, f) != 2) return 0; + int len = ((int)lb[0] << 8) | lb[1]; + if (len < 2) return 0; + int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; + if (is_sof) { + unsigned char so[5]; + if (len < 7 || fread(so, 1, 5, f) != 5) return 0; + int hh = ((int)so[1] << 8) | so[2]; + int ww = ((int)so[3] << 8) | so[4]; + if (ww <= 0 || hh <= 0) return 0; + *w = ww; + *h = hh; + return 1; + } + if (fseek(f, len - 2, SEEK_CUR) != 0) return 0; + } +} + +static int probe_bmp(FILE *f, int *w, int *h) +{ + unsigned char hdr[26]; + if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + if (hdr[0] != 'B' || hdr[1] != 'M') return 0; + int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) | + ((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24)); + int hh = (int)((unsigned)hdr[22] | ((unsigned)hdr[23] << 8) | + ((unsigned)hdr[24] << 16) | ((unsigned)hdr[25] << 24)); + if (hh < 0) hh = -hh; /* 自底向上 BMP */ + if (ww <= 0 || hh <= 0) return 0; + *w = ww; + *h = hh; + return 1; +} + +int mr_probe_size(const char *path, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!path || !path[0] || !w || !h) return 0; + FILE *f = fopen(path, "rb"); + if (!f) return 0; + int ok = probe_png(f, w, h); + if (!ok) { rewind(f); ok = probe_jpeg(f, w, h); } + if (!ok) { rewind(f); ok = probe_bmp(f, w, h); } + fclose(f); + if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; } + return ok; +} + +/* ============================================================ */ +/* mr_book_ops 适配器(目录册) */ +/* ============================================================ */ +static int dir_ops_count(void *ctx) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + return db ? db->count : 0; +} + +static int dir_ops_meta(void *ctx, int index, int *w, int *h) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (w) *w = 0; + if (h) *h = 0; + if (!db || index < 0 || index >= db->count) return 0; + char path[MR_PATH_MAX]; + mr_dir_book_path(db, index, path, (int)sizeof path); + return mr_probe_size(path, w, h); +} + +static meow_img *dir_ops_open(void *ctx, int index) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (!db || index < 0 || index >= db->count) return NULL; + char path[MR_PATH_MAX]; + mr_dir_book_path(db, index, path, (int)sizeof path); + if (!path[0]) return NULL; + return db->load(path, db->ud, db->maxdim); +} + +static void dir_ops_close(void *ctx, meow_img *img) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (db && db->unload && img) db->unload(img); +} + +static void dir_ops_destroy(void *ctx) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (db) mr_dir_book_free(db); +} + +static const mr_book_ops g_dir_ops = { + dir_ops_count, dir_ops_meta, dir_ops_open, dir_ops_close, dir_ops_destroy, +}; + +void mr_dir_book_bind(mr_book *b, mr_dir_book *db) +{ + if (!b) return; + b->ops = db ? &g_dir_ops : NULL; + b->ctx = db; +} diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h new file mode 100644 index 0000000..fe79858 --- /dev/null +++ b/tuku/logic/reader/page_source.h @@ -0,0 +1,91 @@ +/* logic/reader/page_source.h — 页源契约(纯 C,不依赖 meow_ui.h)。 + * + * 一册 = 有序页序列。契约只有四件事: + * count 总页数 + * meta(i) 页的原始像素尺寸(可选;条漫连续滚动先用它排版,未知时返回 0) + * open(i) 取第 i 页:解码 + downscale 后返回 meow_img*(调用方拥有,必须 close) + * close(img) 释放 open 返回的图像 + * + * 两种消费方式共用同一契约,后续实现零改动: + * - 左右翻页(本任务):预解码窗口 = 当前 ±1 页,翻页只 blit 窗口内已备好的图; + * - 条漫连续滚动(M7):先 meta(i) 累加高度排版,再按滚动位置 open(视口±1 页)。 + * zip 册(M9/M11)只需另写一套 mr_book_ops,目录册是第一个实现。 + * + * meow_img 在 logic 层是前向声明的不透明句柄;解码/释放由 UI 层注入回调 + * (logic 不 include SDK,宿主测试用假回调即可覆盖契约边界)。 + */ +#ifndef MR_PAGE_SOURCE_H +#define MR_PAGE_SOURCE_H + +#include "logic/library.h" /* mr_path_is_image / mr_natural_cmp / MR_PATH_MAX / MR_NAME_MAX */ + +#ifdef __cplusplus +extern "C" { +#endif + +struct meow_img; +typedef struct meow_img meow_img; /* SDK 不透明句柄;logic 层只透传 */ + +#define MR_BOOK_MAX_PAGES 4096 /* 单册页数上限(防坏卡/巨目录撑爆内存) */ + +/* 解码回调(UI 层注入):解码 path,长边 >maxdim 时等比 downscale;失败返回 NULL。 + * 必须可在后台线程调用(宿主测试用假实现)。 */ +typedef meow_img *(*mr_book_load_fn)(const char *path, void *ud, int maxdim); +/* 释放 open 返回的图像(UI 层注入 meow_img_free;宿主测试用假实现计数)。 */ +typedef void (*mr_book_unload_fn)(meow_img *img); + +typedef struct mr_book_ops { + int (*count)(void *ctx); + /* 页原始尺寸(未 downscale);1=已知,0=未知。可为 NULL。 */ + int (*meta)(void *ctx, int index, int *w, int *h); + /* 取页:越界/解码失败返回 NULL。返回值的所有权交给调用方。 */ + meow_img *(*open)(void *ctx, int index); + /* 释放 open 返回的图像;须容忍 NULL。 */ + void (*close)(void *ctx, meow_img *img); + /* 释放 provider 自身(册内索引等)。可为 NULL。 */ + void (*destroy)(void *ctx); +} mr_book_ops; + +typedef struct mr_book { + const mr_book_ops *ops; + void *ctx; +} mr_book; + +/* ---- 契约包装:所有入口对 NULL/越界安全(越界 open 返回 NULL) ---- */ +int mr_book_valid(const mr_book *b); +int mr_book_count(const mr_book *b); +int mr_book_meta(const mr_book *b, int index, int *w, int *h); +meow_img *mr_book_open(mr_book *b, int index); +void mr_book_close(mr_book *b, meow_img *img); +/* 释放 provider 自身(不含已 open 的图像——那些由调用方先 close)。幂等。 */ +void mr_book_destroy(mr_book *b); + +/* ---- 目录册(文件夹即书):目录直接子文件里的图片按自然序成册 ---- */ +typedef struct mr_dir_book { + char dir[MR_PATH_MAX]; + char **names; /* 自然序文件名(堆分配) */ + int count; + int maxdim; /* open 时传给解码回调的长边上限 */ + mr_book_load_fn load; + mr_book_unload_fn unload; + void *ud; /* 解码回调的宿主上下文 */ +} mr_dir_book; + +/* 扫描 dir 的直接子文件,收图片名并自然排序。 + * 返回页数(>=0);参数非法 / 目录不可读 / 内存不足返回 -1(db 已清空、可安全 free)。 */ +int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud); +void mr_dir_book_free(mr_dir_book *db); +/* 第 index 页的完整路径;越界时输出空串。 */ +void mr_dir_book_path(const mr_dir_book *db, int index, char *out, int outsz); +/* 把目录册绑定到通用 mr_book(db 生命周期由调用方持有)。 */ +void mr_dir_book_bind(mr_book *b, mr_dir_book *db); + +/* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。 + * PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */ +int mr_probe_size(const char *path, int *w, int *h); + +#ifdef __cplusplus +} +#endif +#endif /* MR_PAGE_SOURCE_H */ diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 26a31da..917ff29 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -6,3 +6,8 @@ gcc -Wall -Wextra -O1 -I. \ test/test_library.c logic/library.c logic/reader.c \ -o test/test_library ./test/test_library +gcc -Wall -Wextra -O1 -I. \ + test/test_reader_core.c logic/library.c logic/reader.c \ + logic/reader/page_source.c logic/reader/nav.c \ + -o test/test_reader_core +./test/test_reader_core diff --git a/tuku/test/test_reader_core.c b/tuku/test/test_reader_core.c new file mode 100644 index 0000000..6edaa58 --- /dev/null +++ b/tuku/test/test_reader_core.c @@ -0,0 +1,452 @@ +/* test_reader_core.c — 阅读器纯逻辑宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * R1 页源契约:空册 / 不可读目录 / 越界 open / 重复 open-close / 首末页 / 自然序 / + * 隐藏文件与子目录排除 / 销毁后失效; + * R2 契约对两种消费方式通用:meta() 给条漫连续滚动排高度,open() 给左右翻页取图; + * R3 图片头探测:PNG/JPEG/BMP 原始尺寸(不解码); + * R4 方向语义:tap 分区 / 拖动增量(RTL 默认与 LTR)/ clamp / 提交阈值; + * R5 fit 三档 / 缩放 / 平移夹取 / 进场方向 / 档位循环; + * R6 阅读方向状态(M3 API)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_reader_core.c logic/library.c logic/reader.c \ + * logic/reader/page_source.c logic/reader/nav.c -o test/test_reader_core + * 或直接 test/run_tests.sh。 + */ +#include "logic/library.h" +#include "logic/reader.h" +#include "logic/reader/nav.h" +#include "logic/reader/page_source.h" + +#include +#include +#include +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +/* ============================================================ */ +/* 假解码器:meow_img* 用堆上假句柄代替,记录 open/close 次数 */ +/* ============================================================ */ +typedef struct fake_img { int id; } fake_img; + +static int g_loads, g_frees; +static int g_last_maxdim; +static char g_last_path[MR_PATH_MAX]; + +static meow_img *fake_load(const char *path, void *ud, int maxdim) +{ + (void)ud; + g_loads++; + g_last_maxdim = maxdim; + snprintf(g_last_path, sizeof g_last_path, "%s", path ? path : ""); + fake_img *f = (fake_img *)malloc(sizeof *f); + if (!f) return NULL; + f->id = g_loads; + return (meow_img *)f; +} + +static void fake_unload(meow_img *img) +{ + g_frees++; + free(img); +} + +/* ============================================================ */ +/* 临时目录工具 */ +/* ============================================================ */ +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int write_bytes(const char *path, const unsigned char *buf, int len) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(buf, 1, (size_t)len, f) == (size_t)len; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int touch_file(const char *path) +{ + return write_bytes(path, (const unsigned char *)"x", 1); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +/* ---- 头文件样例(只写头,不写像素) ---- */ +static int write_png(const char *path, int w, int h) +{ + unsigned char b[24] = { + 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, + 0, 0, 0, 13, 'I', 'H', 'D', 'R', + (unsigned char)(w >> 24), (unsigned char)(w >> 16), + (unsigned char)(w >> 8), (unsigned char)w, + (unsigned char)(h >> 24), (unsigned char)(h >> 16), + (unsigned char)(h >> 8), (unsigned char)h, + }; + return write_bytes(path, b, (int)sizeof b); +} + +static int write_jpeg(const char *path, int w, int h) +{ + unsigned char b[16] = { + 0xFF, 0xD8, /* SOI */ + 0xFF, 0xC0, 0x00, 0x11, /* SOF0, len=17 */ + 0x08, /* precision */ + (unsigned char)(h >> 8), (unsigned char)h, + (unsigned char)(w >> 8), (unsigned char)w, + 0x03, 0x01, 0x11, 0x00, /* 3 分量(长度不足也无妨:只读到 w/h) */ + }; + return write_bytes(path, b, (int)sizeof b); +} + +static int write_bmp(const char *path, int w, int h) +{ + unsigned char b[26] = { 0 }; + b[0] = 'B'; + b[1] = 'M'; + b[18] = (unsigned char)w; b[19] = (unsigned char)(w >> 8); + b[20] = (unsigned char)(w >> 16); b[21] = (unsigned char)(w >> 24); + b[22] = (unsigned char)h; b[23] = (unsigned char)(h >> 8); + b[24] = (unsigned char)(h >> 16); b[25] = (unsigned char)(h >> 24); + return write_bytes(path, b, (int)sizeof b); +} + +/* ============================================================ */ +/* R1/R2 页源契约 */ +/* ============================================================ */ +static void test_contract(void) +{ + char tmpl[] = "./mr_reader_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return; + + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/page10.png", root); write_png(p, 600, 900); + snprintf(p, sizeof p, "%s/page2.jpg", root); write_jpeg(p, 1200, 1800); + snprintf(p, sizeof p, "%s/page1.bmp", root); write_bmp(p, 800, 1200); + snprintf(p, sizeof p, "%s/notes.txt", root); touch_file(p); + snprintf(p, sizeof p, "%s/.hidden.png", root); touch_file(p); + snprintf(p, sizeof p, "%s/sub.png", root); mkdir_p(p); /* 目录不参与成册 */ + snprintf(p, sizeof p, "%s/sub/inside.png", root); touch_file(p); + + mr_dir_book db; + mr_book book; + int n = mr_dir_book_init(&db, root, 640, fake_load, fake_unload, NULL); + CHECK(n == 3, "init count=%d (want 3)", n); + CHECK(db.count == 3, "db.count=%d", db.count); + mr_dir_book_bind(&book, &db); + + CHECK(mr_book_valid(&book) == 1, "book valid"); + CHECK(mr_book_count(&book) == 3, "book count=3"); + CHECK(mr_book_count(NULL) == 0, "NULL count safe"); + CHECK(mr_book_valid(NULL) == 0, "NULL invalid"); + CHECK(mr_book_open(NULL, 0) == NULL, "NULL open safe"); + mr_book_close(NULL, NULL); /* 全程 NULL 安全,不崩 */ + mr_book_destroy(NULL); + + /* 自然序 + 首末页 */ + char path[MR_PATH_MAX]; + mr_dir_book_path(&db, 0, path, sizeof path); + CHECK(strstr(path, "page1.bmp") != NULL, "page 0 = page1 got %s", path); + mr_dir_book_path(&db, 1, path, sizeof path); + CHECK(strstr(path, "page2.jpg") != NULL, "page 1 = page2 got %s", path); + mr_dir_book_path(&db, 2, path, sizeof path); + CHECK(strstr(path, "page10.png") != NULL, "page 2 = page10 got %s", path); + + /* 越界:open/meta/path 全部安全 */ + CHECK(mr_book_open(&book, -1) == NULL, "open(-1) NULL"); + CHECK(mr_book_open(&book, 3) == NULL, "open(count) NULL"); + CHECK(mr_book_open(&book, 9999) == NULL, "open(9999) NULL"); + int mw = -1, mh = -1; + CHECK(mr_book_meta(&book, -1, &mw, &mh) == 0 && mw == 0 && mh == 0, "meta(-1) unknown"); + CHECK(mr_book_meta(&book, 3, &mw, &mh) == 0, "meta(count) unknown"); + mr_dir_book_path(&db, 9, path, sizeof path); + CHECK(path[0] == '\0', "path out of range empty"); + + /* 首末页 open + 重复 open-close(每次 open 的句柄独立,各 close 一次) */ + int loads0 = g_loads, frees0 = g_frees; + meow_img *a = mr_book_open(&book, 0); + CHECK(a != NULL, "open(0) ok"); + CHECK(strstr(g_last_path, "page1.bmp") != NULL, "open(0) path %s", g_last_path); + CHECK(g_last_maxdim == 640, "open passes maxdim=%d", g_last_maxdim); + meow_img *a2 = mr_book_open(&book, 0); + CHECK(a2 != NULL && a2 != a, "re-open(0) gives independent handle"); + CHECK(g_loads == loads0 + 2, "two loads"); + mr_book_close(&book, a); + mr_book_close(&book, a2); + CHECK(g_frees == frees0 + 2, "frees=%d", g_frees - frees0); + + /* 再开再关一轮:契约允许同一页反复 open/close */ + a = mr_book_open(&book, 0); + CHECK(a != NULL && g_loads == loads0 + 3, "re-open after close"); + mr_book_close(&book, a); + CHECK(g_frees == frees0 + 3, "re-close frees"); + + meow_img *last = mr_book_open(&book, 2); + CHECK(last != NULL, "open(last) ok"); + CHECK(strstr(g_last_path, "page10.png") != NULL, "open(last) path %s", g_last_path); + mr_book_close(&book, last); + + /* meta:PNG/JPEG/BMP 头解析 */ + CHECK(mr_book_meta(&book, 0, &mw, &mh) == 1 && mw == 800 && mh == 1200, + "meta bmp=%dx%d", mw, mh); + CHECK(mr_book_meta(&book, 1, &mw, &mh) == 1 && mw == 1200 && mh == 1800, + "meta jpeg=%dx%d", mw, mh); + CHECK(mr_book_meta(&book, 2, &mw, &mh) == 1 && mw == 600 && mh == 900, + "meta png=%dx%d", mw, mh); + + /* R2:契约对条漫通用——用 meta 累加高度,按滚动位置定位页(M7 可直接复用)。 */ + long y = 0, heights[3]; + for (int i = 0; i < 3; i++) { + int sw = 0, sh = 0; + mr_book_meta(&book, i, &sw, &sh); + int fw = 0, fh = 0; + mr_nav_fit_size(MR_FIT_WIDTH, sw, sh, 480, 720, &fw, &fh); + heights[i] = fh; + y += fh; + } + CHECK(y == 720 + 720 + 720, "column height=%ld", y); + CHECK(heights[0] == 720 && heights[1] == 720, "meta-derived fit heights"); + + /* 销毁:provider 失效,二次 destroy 幂等 */ + mr_book_destroy(&book); + CHECK(mr_book_valid(&book) == 0, "destroyed invalid"); + CHECK(mr_book_open(&book, 0) == NULL, "open after destroy NULL"); + mr_book_destroy(&book); + mr_dir_book_free(&db); + + rm_rf(root); +} + +static void test_empty_and_bad(void) +{ + char tmpl[] = "./mr_reader_empty_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp empty"); + + /* 空目录:可读但 0 页(≠ 错误) */ + mr_dir_book db; + int n = mr_dir_book_init(&db, root, 720, fake_load, fake_unload, NULL); + CHECK(n == 0, "empty dir count=0 got %d", n); + CHECK(db.count == 0, "empty db count"); + mr_book book; + mr_dir_book_bind(&book, &db); + CHECK(mr_book_count(&book) == 0, "empty book count 0"); + CHECK(mr_book_open(&book, 0) == NULL, "empty open NULL"); + CHECK(mr_book_meta(&book, 0, NULL, NULL) == 0, "empty meta NULL-safe"); + mr_dir_book_free(&db); + + /* 目录不存在:-1,且 db 可安全 free */ + mr_dir_book bad; + CHECK(mr_dir_book_init(&bad, "/no/such/dir/tuku-reader", 720, fake_load, fake_unload, + NULL) == -1, "missing dir -> -1"); + CHECK(bad.count == 0 && bad.names == NULL, "missing dir cleared"); + mr_dir_book_free(&bad); + + /* 参数非法 */ + CHECK(mr_dir_book_init(NULL, root, 720, fake_load, fake_unload, NULL) == -1, "NULL db"); + CHECK(mr_dir_book_init(&bad, root, 720, NULL, fake_unload, NULL) == -1, "NULL load"); + CHECK(mr_dir_book_init(&bad, root, 720, fake_load, NULL, NULL) == -1, "NULL unload"); + CHECK(mr_dir_book_init(&bad, "", 720, fake_load, fake_unload, NULL) == -1, "empty dirname"); + + rm_rf(root); +} + +/* ============================================================ */ +/* R4 方向语义 */ +/* ============================================================ */ +static void test_direction_nav(void) +{ + const int W = 480; + CHECK(mr_nav_tap_zone(0, W) == MR_ZONE_LEFT, "zone 0"); + CHECK(mr_nav_tap_zone(159, W) == MR_ZONE_LEFT, "zone 159"); + CHECK(mr_nav_tap_zone(160, W) == MR_ZONE_MIDDLE, "zone 160"); + CHECK(mr_nav_tap_zone(319, W) == MR_ZONE_MIDDLE, "zone 319"); + CHECK(mr_nav_tap_zone(320, W) == MR_ZONE_RIGHT, "zone 320"); + CHECK(mr_nav_tap_zone(479, W) == MR_ZONE_RIGHT, "zone 479"); + CHECK(mr_nav_tap_zone(10, 0) == MR_ZONE_MIDDLE, "zone bad width"); + + /* RTL(默认):屏左=下一页,屏右=上一页 */ + CHECK(mr_nav_tap_delta(1, MR_ZONE_LEFT) == 1, "RTL left=next"); + CHECK(mr_nav_tap_delta(1, MR_ZONE_RIGHT) == -1, "RTL right=prev"); + CHECK(mr_nav_tap_delta(1, MR_ZONE_MIDDLE) == 0, "RTL middle=chrome"); + /* LTR:屏左=上一页,屏右=下一页 */ + CHECK(mr_nav_tap_delta(0, MR_ZONE_LEFT) == -1, "LTR left=prev"); + CHECK(mr_nav_tap_delta(0, MR_ZONE_RIGHT) == 1, "LTR right=next"); + CHECK(mr_nav_tap_delta(0, MR_ZONE_MIDDLE) == 0, "LTR middle=chrome"); + + /* 拖动:向右(dx>0)露出左侧那页 */ + CHECK(mr_nav_swipe_delta(1, 120) == 1, "RTL drag right=next"); + CHECK(mr_nav_swipe_delta(1, -120) == -1, "RTL drag left=prev"); + CHECK(mr_nav_swipe_delta(0, 120) == -1, "LTR drag right=prev"); + CHECK(mr_nav_swipe_delta(0, -120) == 1, "LTR drag left=next"); + CHECK(mr_nav_swipe_delta(1, 0) == 0, "drag 0 no-op"); + + CHECK(mr_nav_clamp_page(-5, 10) == 0, "clamp low"); + CHECK(mr_nav_clamp_page(20, 10) == 9, "clamp high"); + CHECK(mr_nav_clamp_page(3, 10) == 3, "clamp inside"); + CHECK(mr_nav_clamp_page(0, 0) == -1, "clamp empty"); + + CHECK(mr_nav_commit_dist(480) == 120, "commit dist 480 -> 120"); + CHECK(mr_nav_commit_dist(200) == 60, "commit dist 200 -> 60"); + CHECK(mr_nav_commit_dist(2000) == 160, "commit dist 2000 -> 160"); + CHECK(mr_nav_commit(119, 480) == 0, "119 not committed"); + CHECK(mr_nav_commit(120, 480) == 1, "120 committed"); + CHECK(mr_nav_commit(-120, 480) == 1, "-120 committed"); + + /* 进场方向:RTL 下一页从左侧进,LTR 下一页从右侧进 */ + CHECK(mr_nav_slide_from(1, 1, W) == -W, "RTL next enters left"); + CHECK(mr_nav_slide_from(1, -1, W) == W, "RTL prev enters right"); + CHECK(mr_nav_slide_from(0, 1, W) == W, "LTR next enters right"); + CHECK(mr_nav_slide_from(0, -1, W) == -W, "LTR prev enters left"); + CHECK(mr_nav_slide_from(1, 0, W) == 0, "slide 0"); +} + +/* ============================================================ */ +/* R5 fit / 缩放 / 平移 */ +/* ============================================================ */ +static void test_fit_zoom_pan(void) +{ + int w = 0, h = 0; + /* 竖图 1000×1500,视口 480×720 */ + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 1000, 1500, 480, 720, &w, &h) == 0 && + w == 480 && h == 720, "fit width %dx%d", w, h); + CHECK(mr_nav_fit_size(MR_FIT_HEIGHT, 1000, 1500, 480, 720, &w, &h) == 0 && + w == 480 && h == 720, "fit height %dx%d", w, h); + CHECK(mr_nav_fit_size(MR_FIT_ORIGINAL, 1000, 1500, 480, 720, &w, &h) == 0 && + w == 1000 && h == 1500, "fit original %dx%d", w, h); + + /* 横图 1500×1000:适宽缩到 480×320,适高放大到 1080×720(可左右平移) */ + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 1500, 1000, 480, 720, &w, &h) == 0 && + w == 480 && h == 320, "landscape fit width %dx%d", w, h); + CHECK(mr_nav_fit_size(MR_FIT_HEIGHT, 1500, 1000, 480, 720, &w, &h) == 0 && + w == 1080 && h == 720, "landscape fit height %dx%d", w, h); + + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 0, 100, 480, 720, &w, &h) == -1, "fit bad src"); + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 100, 100, 0, 720, &w, &h) == -1, "fit bad viewport"); + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 100, 100, 480, 720, NULL, &h) == -1, "fit NULL out"); + + CHECK(mr_nav_zoom_size(480, 720, 200, &w, &h) == 0 && w == 960 && h == 1440, + "zoom 200 %dx%d", w, h); + CHECK(mr_nav_zoom_size(480, 720, 150, &w, &h) == 0 && w == 720 && h == 1080, + "zoom 150 %dx%d", w, h); + CHECK(mr_nav_zoom_size(0, 720, 200, &w, &h) == -1, "zoom bad size"); + + int lx = -1, ly = -1; + mr_nav_pan_limit(960, 1440, 480, 720, &lx, &ly); + CHECK(lx == 240 && ly == 360, "pan limit %d,%d", lx, ly); + mr_nav_pan_limit(480, 720, 480, 720, &lx, &ly); + CHECK(lx == 0 && ly == 0, "no overflow no pan"); + + int px = 9999, py = -9999; + mr_nav_pan_clamp(960, 1440, 480, 720, &px, &py); + CHECK(px == 240 && py == -360, "pan clamp %d,%d", px, py); + px = 10; py = 20; + mr_nav_pan_clamp(480, 720, 480, 720, &px, &py); + CHECK(px == 0 && py == 0, "pan clamp no overflow"); + + CHECK(mr_nav_fit_next(MR_FIT_WIDTH) == MR_FIT_HEIGHT, "fit cycle w->h"); + CHECK(mr_nav_fit_next(MR_FIT_HEIGHT) == MR_FIT_ORIGINAL, "fit cycle h->o"); + CHECK(mr_nav_fit_next(MR_FIT_ORIGINAL) == MR_FIT_WIDTH, "fit cycle o->w"); + CHECK(mr_nav_fit_next(99) == MR_FIT_WIDTH, "fit cycle bad"); +} + +/* ============================================================ */ +/* R3 图片头探测 */ +/* ============================================================ */ +static void test_probe(void) +{ + char tmpl[] = "./mr_reader_probe_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp probe"); + if (!root) return; + + char p[MR_PATH_MAX]; + int w = 0, h = 0; + snprintf(p, sizeof p, "%s/a.png", root); + write_png(p, 1234, 5678); + CHECK(mr_probe_size(p, &w, &h) == 1 && w == 1234 && h == 5678, "probe png %dx%d", w, h); + + snprintf(p, sizeof p, "%s/a.jpg", root); + write_jpeg(p, 1600, 2400); + CHECK(mr_probe_size(p, &w, &h) == 1 && w == 1600 && h == 2400, "probe jpeg %dx%d", w, h); + + snprintf(p, sizeof p, "%s/a.bmp", root); + write_bmp(p, 640, 480); + CHECK(mr_probe_size(p, &w, &h) == 1 && w == 640 && h == 480, "probe bmp %dx%d", w, h); + + snprintf(p, sizeof p, "%s/notes.txt", root); + touch_file(p); + CHECK(mr_probe_size(p, &w, &h) == 0 && w == 0 && h == 0, "probe unknown"); + CHECK(mr_probe_size("/no/such/file.png", &w, &h) == 0, "probe missing"); + CHECK(mr_probe_size(NULL, &w, &h) == 0, "probe NULL"); + + rm_rf(root); +} + +/* ============================================================ */ +/* R6 阅读方向状态 */ +/* ============================================================ */ +static void test_direction_state(void) +{ + mr_reader_set_direction(MR_DIR_RTL); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "default RTL"); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "switch LTR"); + mr_reader_set_direction(42); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "invalid ignored"); + mr_reader_set_direction(MR_DIR_RTL); +} + +int main(void) +{ + test_contract(); + test_empty_and_bad(); + test_probe(); + test_direction_nav(); + test_fit_zoom_pan(); + test_direction_state(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 33de900..19f9b87 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -1,24 +1,1057 @@ -/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏)M3 骨架。 +/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页模式。 * - * M4 在此实现翻页/图片显示;本批只落可编译桩 + 既有接缝: - * - 书库经 mr_reader_open_dir(path) 导航进来(app.c/library.c 不改); - * - 方向状态走 logic/reader 的 mr_reader_get/set_direction(默认 RTL)。 - * 绘制走 view on_draw(注册表驱动),主题切换由 app.c 的 listener invalidate 全屏重画。 + * 用户口径:优先流畅度,其次功能。翻页路径不解码、不重排: + * - 后台 pthread 解码窗口(当前 ±1 页),主循环只取已备好的图; + * - 跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms); + * - 只 flush 受影响区(本页=整屏内容区);翻页/解码/驻留耗时都 meow_log 打点。 * - * 接缝注意(M4):meow_view 的 on_create 在 meow_view_create 内运行(此时 activity 栈空), - * 本页是非根页——控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 - * meow_ui_activity_push(ACT_READER) → 建控件 → meow_ui_activity_pop() - * (examples/hello/ui/demo/demo.c:515-522);否则控件 owner 绑到 ACT_LIBRARY, - * 造成跨页命中/绘制泄漏(§6.1)。 + * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。目录册 + * (文件夹即书)是第一个实现;zip 册与条漫只需另写 provider / 消费方式,本页零改动。 + * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 + * + * 手势(与 SDK 语义对齐,见 sdk/meow_ui_input.c touch_release): + * - 左缘(x<24)向右划 = 系统返回(MEOW_SWIPE_LEFT)→ 不消费,app.c page_pop 退出; + * - 屏底 28px 上滑 = 系统回桌面(MEOW_EV_HOME),SDK 不分发给 App,本页不起手; + * - 中部横拖 = 跟手翻页(本页用 app.c 路由来的原始 down/move 自己算位移); + * - tap 分区方向感知:屏左/右 1/3 = 上/下一页,中 1/3 = chrome 显隐;双击 = 放大。 + * - 物理键不映射(app.c 只记日志)。 + * + * 节拍:app.c 的 on_tick 只转给 library_on_tick(不在本页 scope)。本页用一条常驻 + * meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取与双击判定超时, + * 不新增常驻轮询线程,也不改 on_tick 路由。翻页动画本身也走同一节拍。 + * + * 生命周期:on_enter 建状态 + 建 TopAppBar + 预取首页;on_leave 全量释放(停解码线程、 + * 释放全部页图/册索引/控件)。控件在 on_enter 建 → owner=ACT_READER(非根页防泄漏)。 */ #include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "logic/reader/page_source.h" +#include "logic/reader/nav.h" +#include #include +#include #include +#define MR_WIN 3 /* 预解码窗口:当前 ±1 页 */ +#define MR_TOPBAR_H 56 +#define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */ +#define MR_BOUNCE_MS 120 /* 未过阈值回弹 */ +#define MR_DOUBLE_MS 240 /* 单击等待窗口(区分双击) */ +#define MR_ZOOM_PCT 200 /* 双击放大倍率 */ +#define MR_TICK_MS 16 +#define MR_PAINT_MS 16 /* 跟手拖动重绘节流(≈60fps 上限) */ +#define MR_DRAG_SLOP 8 /* 小于此位移算点击 */ +#define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) /* 缩放临时缓冲软上限(>此值降倍率,保内存) */ + +#define MR_REQ_MAX 8 +#define MR_RES_MAX 4 + +/* view on_event 的事件号不在公共头(sdk/meow_ui_internal.h §6.4);本页只用到 SWIPE=3。 */ +#define MR_EV_SWIPE 3 + static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; +static mr_dir_book g_db; +static mr_book g_book; +static int g_count; /* 0=空册/不可读 */ +static int g_open_err; +static int g_page; + +static int g_vw, g_vh; /* 内容视口 = 全屏 */ +static int g_fit = MR_FIT_WIDTH; +static int g_zoom_pct = 100; +static int g_pan_x, g_pan_y; +static int g_chrome = 1; +static meow_widget *g_tb; + +/* ---- 页图缓存(≤3 张 downscale 后的图)---- */ +typedef struct page_slot { + int idx; /* -1=空 */ + meow_img *img; /* 已 downscale 的页图 */ + int src_w, src_h; +} page_slot; +static page_slot g_slot[MR_WIN]; + +/* ---- 后台解码队列 ---- */ +typedef struct page_req { int idx; unsigned gen; } page_req; +typedef struct page_res { int idx; unsigned gen; meow_img *img; } page_res; + +static pthread_t g_worker; +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; +static int g_worker_on, g_stop; +static unsigned g_gen = 1; + +static page_req g_req[MR_REQ_MAX]; +static int g_req_head, g_req_tail, g_req_n; +static page_res g_res[MR_RES_MAX]; +static int g_res_n; + +/* ---- 节拍/动画 ---- */ +static meow_anim *g_ticker; +static meow_anim *g_snap; /* 翻页滑动吸附 */ +static meow_anim *g_bounce; /* 未过阈值回弹 */ +static int g_slide_delta; /* 翻页目标增量(动画中) */ +static int g_slide_from; /* 进场页起点(px) */ +static int g_anim_v; /* 进场页当前位移(px) */ +static uint64_t g_flip_t0; +static uint64_t g_last_tick; +static uint64_t g_last_paint; +static int g_quitting; /* on_leave 期间抑制回调 */ + +/* ---- 跟手拖动 ---- */ +static int g_dragging; /* 1=本次按下正在拖动 */ +static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 */ +static int g_drag_x0, g_drag_y0; +static int g_drag_off; /* 当前页水平位移(px) */ +static int g_drag_pan_x0, g_drag_pan_y0; +static int g_touch_moved; /* 本次按下是否已产生 >slop 的位移 */ + +/* ---- 单击延迟(双击判定窗口)---- */ +static int g_tap_pending; +static int g_tap_x, g_tap_y; +static uint64_t g_tap_ms; +static uint64_t g_last_tap_ms; /* 上一次点击(跨按下保持,双击判定用) */ +static int g_last_tap_x, g_last_tap_y; + +/* ============================================================ */ +/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ +/* ============================================================ */ +static meow_img *reader_load_page(const char *path, void *ud, int maxdim) +{ + (void)ud; + meow_img *im = meow_img_load_file(path); + if (!im) return NULL; + meow_img *small = meow_img_downscale(im, maxdim); /* 大图先缩再驻留 */ + if (small) { meow_img_free(im); im = small; } + return im; +} + +static void reader_unload_page(meow_img *img) +{ + meow_img_free(img); +} + +/* ============================================================ */ +/* 内存观测(验收用:进出 reader 对比) */ +/* ============================================================ */ +static long mem_kb(const char *path, const char *key) +{ + FILE *f = fopen(path, "r"); + if (!f) return -1; + char line[128]; + long v = -1; + size_t kl = strlen(key); + while (fgets(line, sizeof line, f)) { + if (strncmp(line, key, kl) == 0) { v = strtol(line + kl, NULL, 10); break; } + } + fclose(f); + return v; +} + +static int resident_pages(void) +{ + int n = 0; + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].img) n++; + return n; +} + +static long resident_bytes(void) +{ + long b = 0; + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].img) b += (long)g_slot[i].src_w * g_slot[i].src_h * 4; + return b; +} + +static void log_mem(const char *tag) +{ + meow_log(MEOW_LOG_INFO, "reader mem[%s]: avail=%ldKB rss=%ldKB pages=%d img=%ldKB", + tag, mem_kb("/proc/meminfo", "MemAvailable:"), + mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); +} + +/* ============================================================ */ +/* 页图缓存 */ +/* ============================================================ */ +static page_slot *slot_find(int idx) +{ + if (idx < 0) return NULL; + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].idx == idx && g_slot[i].img) return &g_slot[i]; + return NULL; +} + +static void slot_clear(int i) +{ + if (i < 0 || i >= MR_WIN) return; + if (g_slot[i].img) meow_img_free(g_slot[i].img); + g_slot[i].img = NULL; + g_slot[i].idx = -1; + g_slot[i].src_w = g_slot[i].src_h = 0; +} + +static void slots_free_all(void) +{ + for (int i = 0; i < MR_WIN; i++) slot_clear(i); +} + +/* 放进缓存;缓存满时淘汰离当前页最远的一张。返回 1=已放入。 */ +static int slot_put(int idx, meow_img *img) +{ + if (!img || idx < 0) return 0; + int empty = -1, far = -1, far_d = -1; + for (int i = 0; i < MR_WIN; i++) { + if (g_slot[i].idx == idx && g_slot[i].img) { meow_img_free(img); return 0; } + if (!g_slot[i].img && empty < 0) empty = i; + int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - g_page); + if (d > far_d) { far_d = d; far = i; } + } + int i = empty >= 0 ? empty : far; + if (i < 0) { meow_img_free(img); return 0; } + slot_clear(i); + g_slot[i].idx = idx; + g_slot[i].img = img; + g_slot[i].src_w = meow_img_w(img); + g_slot[i].src_h = meow_img_h(img); + return 1; +} + +/* 当前窗口:正常=当前±1;放大时只驻留当前页(给缩放临时缓冲让内存)。 */ +static int in_window(int idx) +{ + if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; + if (g_zoom_pct > 100) return idx == g_page; + return idx >= g_page - 1 && idx <= g_page + 1; +} + +/* ============================================================ */ +/* 后台解码 */ +/* ============================================================ */ +static int req_queued(int idx) +{ + for (int i = 0; i < g_req_n; i++) { + int p = (g_req_head + i) % MR_REQ_MAX; + if (g_req[p].idx == idx) return 1; + } + return 0; +} + +static void queue_page(int idx) +{ + if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; + if (slot_find(idx)) return; + pthread_mutex_lock(&g_lock); + if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { + g_req[g_req_tail].idx = idx; + g_req[g_req_tail].gen = g_gen; + g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; + g_req_n++; + pthread_cond_signal(&g_cv); + } + pthread_mutex_unlock(&g_lock); +} + +static void *reader_worker(void *arg) +{ + (void)arg; + for (;;) { + page_req req; + pthread_mutex_lock(&g_lock); + while (!g_stop && g_req_n == 0) pthread_cond_wait(&g_cv, &g_lock); + if (g_stop) { pthread_mutex_unlock(&g_lock); break; } + req = g_req[g_req_head]; + g_req_head = (g_req_head + 1) % MR_REQ_MAX; + g_req_n--; + pthread_mutex_unlock(&g_lock); + + uint64_t t0 = meow_ui_time_ms(); + meow_img *img = mr_book_open(&g_book, req.idx); + uint64_t dt = meow_ui_time_ms() - t0; + int w = img ? meow_img_w(img) : 0; + int h = img ? meow_img_h(img) : 0; + meow_log(MEOW_LOG_INFO, "reader decode page=%d ms=%llu %dx%d %s", + req.idx, (unsigned long long)dt, w, h, img ? "ok" : "fail"); + + pthread_mutex_lock(&g_lock); + if (img && !g_stop && g_res_n < MR_RES_MAX) { + g_res[g_res_n].idx = req.idx; + g_res[g_res_n].gen = req.gen; + g_res[g_res_n].img = img; + g_res_n++; + img = NULL; + } + pthread_mutex_unlock(&g_lock); + if (img) meow_img_free(img); + } + return NULL; +} + +static void worker_start(void) +{ + if (g_worker_on) return; + g_stop = 0; + if (pthread_create(&g_worker, NULL, reader_worker, NULL) == 0) g_worker_on = 1; +} + +static void worker_stop(void) +{ + if (!g_worker_on) return; + pthread_mutex_lock(&g_lock); + g_stop = 1; + pthread_cond_broadcast(&g_cv); + pthread_mutex_unlock(&g_lock); + pthread_join(g_worker, NULL); + g_worker_on = 0; + + pthread_mutex_lock(&g_lock); + for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + g_res_n = 0; + g_req_n = 0; + g_req_head = g_req_tail = 0; + pthread_mutex_unlock(&g_lock); +} + +/* 丢弃窗口外的排队请求(主线程调用)。 */ +static void queue_prune(void) +{ + pthread_mutex_lock(&g_lock); + page_req keep[MR_REQ_MAX]; + int n = 0; + for (int i = 0; i < g_req_n; i++) { + page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; + if (r.gen == g_gen && in_window(r.idx)) keep[n++] = r; + } + for (int i = 0; i < n; i++) g_req[i] = keep[i]; + g_req_head = 0; + g_req_tail = n; + g_req_n = n; + pthread_mutex_unlock(&g_lock); +} + +/* 收取后台结果:只留窗口内新页。返回 1=有图落位。 */ +static int results_drain(void) +{ + page_res got[MR_RES_MAX]; + int n; + pthread_mutex_lock(&g_lock); + n = g_res_n; + for (int i = 0; i < n; i++) got[i] = g_res[i]; + g_res_n = 0; + pthread_mutex_unlock(&g_lock); + + int changed = 0; + for (int i = 0; i < n; i++) { + if (got[i].gen == g_gen && in_window(got[i].idx) && !slot_find(got[i].idx)) { + if (slot_put(got[i].idx, got[i].img)) changed = 1; + } else { + meow_img_free(got[i].img); + } + } + return changed; +} + +/* 窗口随当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 补齐缺失页(当前页优先)。 */ +static void window_update(void) +{ + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].idx >= 0 && !in_window(g_slot[i].idx)) slot_clear(i); + queue_prune(); + + queue_page(g_page); + if (g_page - 1 >= 0) queue_page(g_page - 1); + if (g_page + 1 < g_count) queue_page(g_page + 1); +} + +/* ============================================================ */ +/* 布局/绘制 */ +/* ============================================================ */ +static void page_target_size(const page_slot *s, int *w, int *h) +{ + *w = *h = 0; + if (!s) return; + if (mr_nav_fit_size(g_fit, s->src_w, s->src_h, g_vw, g_vh, w, h) != 0) return; + if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); +} + +static void current_geom(int *w, int *h) +{ + page_slot *s = slot_find(g_page); + if (s) { page_target_size(s, w, h); return; } + /* 页未就绪:用 meta 估算,保证平移/缩放的夹取稳定。 */ + int sw = 0, sh = 0; + if (mr_book_meta(&g_book, g_page, &sw, &sh) && sw > 0 && sh > 0) { + mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h); + if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); + return; + } + *w = g_vw; + *h = g_vh; +} + +static void clamp_pan(void) +{ + int w, h; + current_geom(&w, &h); + mr_nav_pan_clamp(w, h, g_vw, g_vh, &g_pan_x, &g_pan_y); +} + +/* 内容是否超出视口(原始档的方图/宽图即使不放大也需要平移)。 */ +static int content_overflow(void) +{ + int w, h; + current_geom(&w, &h); + return w > g_vw || h > g_vh; +} + +/* 平移模式:放大,或原始档溢出。此时单指拖动=平移,左右 tap=平移步进。 */ +static int pan_mode(void) +{ + return g_zoom_pct > 100 || content_overflow(); +} + +/* 画一页(带水平位移);图与目标尺寸相等时走原生 blit(翻页热路径),否则最近邻缩放。 */ +static void draw_page_at(int idx, int off_x, int off_y) +{ + page_slot *s = slot_find(idx); + if (!s) return; + int w, h; + page_target_size(s, &w, &h); + if (w <= 0 || h <= 0) return; + int x = (g_vw - w) / 2 + g_pan_x + off_x; + int y = (g_vh - h) / 2 + g_pan_y + off_y; + if (w == s->src_w && h == s->src_h) meow_ui_img(x, y, s->img); + else meow_ui_img_scaled(x, y, w, h, s->img); +} + +static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = g_vw, h = g_vh; + + if (g_open_err) { + meow_ui_text_center(w / 2, h / 2 - 20, "无法打开目录", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, h / 2 + 24, g_dir, th->font_body, th->onSurfaceVariant); + return; + } + if (g_count <= 0) { + meow_ui_text_center(w / 2, h / 2 - 20, "空册", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, h / 2 + 24, "这个文件夹里没有图片", th->font_body, + th->onSurfaceVariant); + return; + } + + if (g_slide_delta != 0) { + /* 动画帧:进场页从 from 滑到 0,当前页同步让位(只 blit 两张已缓存页图)。 */ + draw_page_at(g_page, g_anim_v - g_slide_from, 0); + draw_page_at(g_page + g_slide_delta, g_anim_v, 0); + return; + } + + if (g_drag_mode == 1 && g_drag_off != 0) { + /* 跟手:当前页按位移走,相邻页在另一侧同时露出(未就绪就只画当前页)。 */ + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + int delta = mr_nav_swipe_delta(rtl, g_drag_off); + int target = mr_nav_clamp_page(g_page + delta, g_count); + if (target != g_page && slot_find(target)) { + int from = mr_nav_slide_from(rtl, delta, g_vw); + draw_page_at(target, g_drag_off + from, 0); + } + draw_page_at(g_page, g_drag_off, 0); + return; + } + + draw_page_at(g_page, 0, 0); + if (!slot_find(g_page)) { + meow_ui_text_center(w / 2, h / 2, "加载中…", th->font_body, th->onSurfaceVariant); + } +} + +/* ============================================================ */ +/* 状态动作 */ +/* ============================================================ */ +static void update_title(void) +{ + if (!g_tb) return; + static const char *const fit_name[MR_FIT_COUNT] = { "适宽", "适高", "原始" }; + const char *fit = (g_fit >= 0 && g_fit < MR_FIT_COUNT) ? fit_name[g_fit] : "适宽"; + char buf[64]; + if (g_count > 0) snprintf(buf, sizeof buf, "%d / %d %s", g_page + 1, g_count, fit); + else snprintf(buf, sizeof buf, "0 / 0"); + meow_topappbar_set_title(g_tb, buf); +} + +static void set_fit(int fit) +{ + g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + update_title(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void set_chrome(int on) +{ + on = on ? 1 : 0; + if (g_chrome == on) return; + g_chrome = on; + if (g_tb) meow_widget_set_rect(g_tb, 0, on ? 0 : -MR_TOPBAR_H, g_vw, MR_TOPBAR_H); + meow_ui_invalidate(0, 0, g_vw, MR_TOPBAR_H); +} + +/* 平移(dx>0 = 内容右移);放大或原始档溢出时可用。 */ +static void pan_by(int dx, int dy) +{ + if (!pan_mode()) return; + g_pan_x += dx; + g_pan_y += dy; + clamp_pan(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void commit_page(int target) +{ + target = mr_nav_clamp_page(target, g_count); + if (target < 0) return; + g_page = target; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + window_update(); + update_title(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +/* ---- 翻页滑动吸附 ---- */ +static void slide_update(meow_anim *a, float value, void *ctx) +{ + (void)a; (void)ctx; + g_anim_v = (int)value; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void slide_done(meow_anim *a, int canceled, void *ctx) +{ + (void)a; (void)ctx; + g_snap = NULL; + if (g_quitting) return; + int delta = g_slide_delta; + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + if (!canceled && delta) + commit_page(g_page + delta); + else + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader flip done page=%d/%d total_ms=%llu", + g_page + 1, g_count, + (unsigned long long)(meow_ui_time_ms() - g_flip_t0)); +} + +/* 从 start_off(松手时的位移,无跟手时为 0)滑到目标页。1=动画已启动。 */ +static int start_slide(int delta, int start_off) +{ + if (g_snap || g_bounce) return 0; + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + g_slide_delta = delta; + g_slide_from = mr_nav_slide_from(rtl, delta, g_vw); + g_anim_v = start_off + g_slide_from; + meow_anim_desc d = { + (float)g_anim_v, 0.0f, MR_SLIDE_MS, MEOW_EASE_OUT, + slide_update, slide_done, NULL, + }; + uint64_t t0 = meow_ui_time_ms(); + g_snap = meow_anim_start(&d); /* start 即回调 update:首帧在这里绘制 */ + uint64_t dt = meow_ui_time_ms() - t0; + if (!g_snap) { + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + commit_page(g_page + delta); + return 0; + } + meow_log(MEOW_LOG_INFO, "reader slide first_frame_ms=%llu page=%d->%d", + (unsigned long long)dt, g_page + 1, g_page + delta + 1); + return 1; +} + +/* ---- 未过阈值回弹 ---- */ +static void bounce_update(meow_anim *a, float value, void *ctx) +{ + (void)a; (void)ctx; + g_drag_off = (int)value; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void bounce_done(meow_anim *a, int canceled, void *ctx) +{ + (void)a; (void)canceled; (void)ctx; + g_bounce = NULL; + g_drag_off = 0; + g_drag_mode = 0; + if (!g_quitting) meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void start_bounce(int from_off) +{ + if (g_bounce || g_snap) { g_drag_off = 0; g_drag_mode = 0; return; } + meow_anim_desc d = { + (float)from_off, 0.0f, MR_BOUNCE_MS, MEOW_EASE_OUT, + bounce_update, bounce_done, NULL, + }; + g_bounce = meow_anim_start(&d); + if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; meow_ui_invalidate(0, 0, g_vw, g_vh); } +} + +/* 点击触发的翻页(无跟手位移)。 */ +static void page_flip(int delta) +{ + if (delta == 0 || g_count <= 0) return; + int target = mr_nav_clamp_page(g_page + delta, g_count); + if (target == g_page) return; /* 首/末页不动 */ + if (g_snap || g_bounce) return; /* 短动画中忽略连点 */ + if (g_zoom_pct != 100) { /* 翻页回到基准缩放 */ + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + } + results_drain(); + + g_flip_t0 = meow_ui_time_ms(); + if (slot_find(target)) { + if (start_slide(delta, 0)) + meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1); + else + meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1); + } else { + meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1); + commit_page(target); + } +} + +/* ============================================================ */ +/* 跟手拖动 */ +/* ============================================================ */ +static void drag_paint(void) +{ + uint64_t now = meow_ui_time_ms(); + int gap = g_zoom_pct > 100 ? 33 : MR_PAINT_MS; /* 缩放帧要重采样:30fps 上限 */ + if (now - g_last_paint < (uint64_t)gap) return; + g_last_paint = now; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +/* 松手:过阈值且目标页已缓存 → 从当前位移吸附过去;否则回弹。 */ +static void drag_finish(void) +{ + if (!g_dragging) return; + g_dragging = 0; + int dx = g_drag_off; + int mode = g_drag_mode; + g_drag_off = 0; + g_drag_mode = 0; + + if (mode != 1 || abs(dx) < MR_DRAG_SLOP) { + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + int delta = mr_nav_swipe_delta(rtl, dx); + int target = mr_nav_clamp_page(g_page + delta, g_count); + if (target != g_page && mr_nav_commit(dx, g_vw)) { + g_flip_t0 = meow_ui_time_ms(); + if (slot_find(target) && start_slide(delta, dx)) { + meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); + } else { + meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)", + g_page + 1, target + 1); + commit_page(target); + } + } else { + g_drag_off = dx; /* start_bounce 从这里起 */ + g_drag_mode = 1; + start_bounce(dx); + } +} + +void reader_on_touch_down(int x, int y) +{ + if (!g_ctrl.active || g_quitting) return; + if (g_count <= 0) return; + if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ + if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ + if (g_chrome && y < MR_TOPBAR_H) return; /* chrome 上的点击交给 tap */ + if (g_snap || g_bounce) return; /* 短动画中不接新手势 */ + + g_dragging = 1; + g_drag_mode = pan_mode() ? 2 : 1; + g_drag_x0 = x; + g_drag_y0 = y; + g_drag_off = 0; + g_drag_pan_x0 = g_pan_x; + g_drag_pan_y0 = g_pan_y; + g_touch_moved = 0; +} + +void reader_on_touch_move(int x, int y) +{ + if (!g_ctrl.active || !g_dragging || g_quitting) return; + int dx = x - g_drag_x0; + int dy = y - g_drag_y0; + int adx = abs(dx), ady = abs(dy); + if (!g_touch_moved && (adx >= MR_DRAG_SLOP || ady >= MR_DRAG_SLOP)) { + g_touch_moved = 1; + g_tap_pending = 0; /* 变成拖动:作废未决单击 */ + g_last_tap_ms = 0; + } + + if (g_drag_mode == 2) { /* 放大态:单指拖拽平移 */ + g_pan_x = g_drag_pan_x0 + dx; + g_pan_y = g_drag_pan_y0 + dy; + clamp_pan(); + drag_paint(); + return; + } + + /* 竖向主导 → 放弃横滑翻页(松手后不算点击,见 reader_on_tap)。 */ + if (ady > adx * 3 / 2 && ady > 16) { + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } + + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + int delta = mr_nav_swipe_delta(rtl, dx); + int target = mr_nav_clamp_page(g_page + delta, g_count); + int off = dx; + if (target == g_page) { /* 首/末页:橡皮筋 */ + int lim = g_vw / 4; + if (off > lim) off = lim; + if (off < -lim) off = -lim; + } + g_drag_off = off; + drag_paint(); +} + +/* ============================================================ */ +/* 单击 / 双击 */ +/* ============================================================ */ +static void tap_topbar(int x, int y) +{ + if (!g_tb) return; + int hit = meow_topappbar_hit(g_tb, x, y); + if (hit == MEOW_TOPBAR_BACK) { + meow_view_page_pop(); + return; + } + if (hit >= 0) set_fit(mr_nav_fit_next(g_fit)); +} + +static void tap_content(int x, int y) +{ + (void)y; + if (g_count <= 0) return; + int zone = mr_nav_tap_zone(x, g_vw); + if (zone == MR_ZONE_MIDDLE) { + set_chrome(!g_chrome); + return; + } + if (pan_mode()) { + /* 平移模式:左右分区改为平移步进(配合拖拽,补齐两个方向)。 */ + pan_by(zone == MR_ZONE_LEFT ? g_vw / 3 : -g_vw / 3, 0); + return; + } + page_flip(mr_nav_tap_delta(mr_reader_get_direction() == MR_DIR_RTL, zone)); +} + +static void tap_double(int x, int y) +{ + if (g_count <= 0) return; + if (g_zoom_pct > 100) { + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + } else { + /* 以双击点为锚放大:该点保持在手指下(再夹到可平移范围)。 + * 倍率按内存纪律夹取:缩放临时缓冲 = 目标宽×高×4,超过软上限就降档。 */ + int bw = 0, bh = 0; + current_geom(&bw, &bh); + int z = MR_ZOOM_PCT; + while (z > 100 && (long long)bw * bh * 4 * z * z / 10000 > MR_ZOOM_TEMP_CAP) z -= 25; + g_zoom_pct = z; + g_pan_x = (g_vw / 2 - x) * (z - 100) / 100; + g_pan_y = (g_vh / 2 - y) * (z - 100) / 100; + clamp_pan(); + } + window_update(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void tap_flush(void) +{ + if (!g_tap_pending) return; + g_tap_pending = 0; + int x = g_tap_x, y = g_tap_y; + if (g_chrome && y >= 0 && y < MR_TOPBAR_H) tap_topbar(x, y); + else tap_content(x, y); +} + +void reader_on_tap(int x, int y) +{ + if (!g_ctrl.active || g_quitting) return; + + /* 跟手手势收尾:移动过就按拖动处理(过阈值翻页 / 否则回弹),不再当点击。 */ + if (g_dragging) { + if (g_touch_moved) { + drag_finish(); + g_touch_moved = 0; + return; + } + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + } else if (g_touch_moved) { + g_touch_moved = 0; /* 竖向拖动被 SDK 报成 TAP:忽略 */ + return; + } + g_touch_moved = 0; + + /* chrome 上的点击即时生效,不参与双击判定。 */ + if (g_chrome && y >= 0 && y < MR_TOPBAR_H) { + g_tap_pending = 0; + g_last_tap_ms = 0; + tap_topbar(x, y); + return; + } + + uint64_t now = meow_ui_time_ms(); + if (g_last_tap_ms && now - g_last_tap_ms <= MR_DOUBLE_MS && + abs(x - g_last_tap_x) <= 48 && abs(y - g_last_tap_y) <= 48) { + g_tap_pending = 0; /* 取消第一击的单击动作 */ + g_last_tap_ms = 0; + tap_double(x, y); + return; + } + tap_flush(); /* 上一击已过期:先执行 */ + g_tap_pending = 1; + g_tap_x = x; + g_tap_y = y; + g_tap_ms = now; + g_last_tap_ms = now; + g_last_tap_x = x; + g_last_tap_y = y; + if (!g_ticker) tap_flush(); /* 无节拍器兜底:不延迟单击 */ +} + +/* ============================================================ */ +/* 节拍:收结果 + 单击超时 */ +/* ============================================================ */ +static void reader_tick(void) +{ + if (!g_ctrl.active || g_quitting) return; + uint64_t now = meow_ui_time_ms(); + if (now - g_last_tick < MR_TICK_MS) return; + g_last_tick = now; + + int had_current = slot_find(g_page) != NULL; + if (results_drain() && !had_current && slot_find(g_page) && !g_dragging) + meow_ui_invalidate(0, 0, g_vw, g_vh); + + if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush(); +} + +static void ticker_update(meow_anim *a, float value, void *ctx) +{ + (void)a; (void)value; (void)ctx; + reader_tick(); +} + +static void ticker_done(meow_anim *a, int canceled, void *ctx) +{ + (void)a; (void)ctx; + g_ticker = NULL; + if (canceled || g_quitting || !g_ctrl.active) return; + meow_anim_desc d = { 0.0f, 1.0f, 0xFFFFFFFFu, MEOW_EASE_LINEAR, + ticker_update, ticker_done, NULL }; + g_ticker = meow_anim_start(&d); +} + +static void ticker_start(void) +{ + if (g_ticker) return; + meow_anim_desc d = { 0.0f, 1.0f, 0xFFFFFFFFu, MEOW_EASE_LINEAR, + ticker_update, ticker_done, NULL }; + g_ticker = meow_anim_start(&d); + if (!g_ticker) meow_log(MEOW_LOG_WARN, "reader ticker unavailable (anim pool full)"); +} + +/* ============================================================ */ +/* 输入:滑动手势(view on_event,先于 app.c 的 on_swipe) */ +/* ============================================================ */ +static int reader_event(meow_view *v, int ev, int a, int b, void *ctx) +{ + (void)v; (void)ctx; + if (!g_ctrl.active || g_quitting) return 0; + if (ev != MR_EV_SWIPE) return 0; + + if (a == MEOW_SWIPE_LEFT) { /* 左缘返回:交给 app.c page_pop */ + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + g_touch_moved = 0; + return 0; + } + if (a == MEOW_SWIPE_RIGHT) { + if (g_dragging) { drag_finish(); return 1; } + if (g_count <= 0) return 1; + if (pan_mode()) { pan_by(b, 0); return 1; } + page_flip(mr_nav_swipe_delta(mr_reader_get_direction() == MR_DIR_RTL, b)); + return 1; + } + if (a == MEOW_SWIPE_UP || a == MEOW_SWIPE_DOWN) { + if (g_dragging) { drag_finish(); return 1; } + if (pan_mode()) { /* 放大 / 原始档溢出:纵向滑动平移 */ + pan_by(0, a == MEOW_SWIPE_DOWN ? b : -b); + return 1; + } + return 0; /* 未放大:纵向滑动不归本页 */ + } + return 0; +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ +static void reader_state_reset(void) +{ + slots_free_all(); /* 静态槽位初值是 idx=0/img=NULL,先清空 */ + g_count = 0; + g_open_err = 0; + g_page = 0; + g_fit = MR_FIT_WIDTH; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + g_chrome = 1; + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + g_touch_moved = 0; + g_tap_pending = 0; + g_last_tap_ms = 0; + g_quitting = 0; +} + +static void reader_page_enter(meow_view *v, void *ctx) +{ + mr_page_ctrl *c = (mr_page_ctrl *)ctx; + c->active = 1; + g_quitting = 0; + + g_vw = meow_ui_width(); + g_vh = meow_ui_height(); + v->x = 0; + v->y = 0; + v->w = g_vw; + v->h = g_vh; + + meow_ui_set_fullscreen(1); + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->surface); + + reader_state_reset(); + g_gen++; /* 作废旧册的异步结果 */ + g_last_tick = 0; + g_last_paint = 0; + + /* 非根页:控件在 on_enter 建 → owner=ACT_READER(§6.1 跨页泄漏防线)。 */ + if (!g_tb) g_tb = meow_topappbar_create(0, 0, g_vw, MR_TOPBAR_H, ""); + if (g_tb) { + meow_topappbar_set_back(g_tb, 1); + int ai = meow_topappbar_add_action(g_tb, ""); + if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_REFRESH); + meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOPBAR_H); + } + + /* 页源:目录册(文件夹即书)。maxdim 取屏幕长边,页图驻留 ≈ 屏宽×屏高×4。 */ + int maxdim = g_vw > g_vh ? g_vw : g_vh; + int n = mr_dir_book_init(&g_db, g_dir, maxdim, + reader_load_page, reader_unload_page, NULL); + if (n < 0) { + g_open_err = 1; + meow_log(MEOW_LOG_WARN, "reader open failed: %s", g_dir); + } else { + g_count = n; + } + mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); + + worker_start(); + if (!g_worker_on && g_count > 0) { + /* 线程创建失败:退化为主线程同步解第一页(保证不是纯白屏)。 */ + meow_img *first = mr_book_open(&g_book, 0); + if (first) slot_put(0, first); + meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); + } + window_update(); /* 当前页优先进队 */ + ticker_start(); + update_title(); + + meow_log(MEOW_LOG_INFO, "reader enter dir=%s pages=%d fit=%d", g_dir, g_count, g_fit); + log_mem("enter"); + mr_nav_report(); + meow_ui_paint_full(); + + /* 首页解码是异步的:先给一帧文字/页码,结果在 tick 里落位。 */ + reader_tick(); +} + +static void reader_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; + g_quitting = 1; + + if (g_snap) { meow_anim_cancel(g_snap); g_snap = NULL; } + if (g_bounce) { meow_anim_cancel(g_bounce); g_bounce = NULL; } + if (g_ticker) { meow_anim_cancel(g_ticker); g_ticker = NULL; } + + worker_stop(); /* 先停线程,再释放册/图 */ + slots_free_all(); + mr_book_destroy(&g_book); + mr_dir_book_free(&g_db); + + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + g_tap_pending = 0; + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + + log_mem("leave"); + meow_ui_set_fullscreen(0); +} + +meow_view *reader_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = reader_page_enter, + .on_leave = reader_page_leave, + .on_draw = reader_draw, + .on_event = reader_event, + }; + return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} + +/* ============================================================ */ +/* 书库 → 阅读器导航(app.c / library.c 既有接缝) */ +/* ============================================================ */ const char *mr_reader_dir(void) { return g_dir; @@ -31,61 +1064,3 @@ void mr_reader_open_dir(const char *path) meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); meow_view_page_push(ACT_READER); } - -void reader_on_tap(int x, int y) -{ - (void)x; (void)y; - if (!g_ctrl.active) return; - mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL ? MR_DIR_LTR : MR_DIR_RTL); - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); -} - -static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) -{ - (void)v; (void)ctx; - int w = meow_ui_width(); - const char *dir = g_dir[0] ? g_dir : "(no directory)"; - size_t n = strlen(dir); - if (n > 64) dir += n - 64; - - meow_ui_text_center(w / 2, 160, "READER", th->font_title, th->primary); - meow_ui_text_center(w / 2, 200, "(M4 will render pages here)", th->font_body, - th->onSurfaceVariant); - meow_ui_text_center(w / 2, 280, "Folder:", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, 308, dir, th->font_body, th->onSurface); - meow_ui_text_center(w / 2, 372, - mr_reader_get_direction() == MR_DIR_RTL ? "Direction: RTL" : "Direction: LTR", - th->font_body, th->onSurface); - meow_ui_text_center(w / 2, 420, "tap = toggle direction", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, 448, "left-edge swipe = back", th->font_body, th->onSurfaceVariant); -} - -static void reader_page_enter(meow_view *v, void *ctx) -{ - ((mr_page_ctrl *)ctx)->active = 1; - v->x = 0; - v->y = 0; - v->w = meow_ui_width(); - v->h = meow_ui_height(); - meow_ui_set_fullscreen(1); /* app.json route.reader.fullscreen 的运行时兜底 */ - meow_ui_set_page_bg(meow_theme_get()->bg); - mr_nav_report(); - meow_ui_paint_full(); -} - -static void reader_page_leave(meow_view *v, void *ctx) -{ - (void)v; - ((mr_page_ctrl *)ctx)->active = 0; - meow_ui_set_fullscreen(0); -} - -meow_view *reader_view_create(void) -{ - static const meow_view_hooks h = { - .on_enter = reader_page_enter, - .on_leave = reader_page_leave, - .on_draw = reader_draw, - }; - return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); -} diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h new file mode 100644 index 0000000..62c7cc3 --- /dev/null +++ b/tuku/ui/reader/reader.h @@ -0,0 +1,19 @@ +/* ui/reader/reader.h — 阅读页给 app.c 的事件接缝(跟手拖动/平移)。 + * + * app.c 只做最小路由:ACT_READER 时的 on_touch_down/move 转给这里(镜像 library 分支)。 + * 其余入口(on_tap / view on_event / mr_reader_open_dir)仍走 tuku_internal.h 的既有声明。 + */ +#ifndef TUKU_UI_READER_READER_H +#define TUKU_UI_READER_READER_H + +#ifdef __cplusplus +extern "C" { +#endif + +void reader_on_touch_down(int x, int y); +void reader_on_touch_move(int x, int y); + +#ifdef __cplusplus +} +#endif +#endif /* TUKU_UI_READER_READER_H */ From 21bffa1aeb1f21e4d1175386666a87c4152a71d3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 21:01:42 +0800 Subject: [PATCH 06/38] =?UTF-8?q?sdk:=20=E5=9B=BE=E7=89=87=E5=B1=82?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E9=A2=84=E7=AE=97=E8=A7=A3=E7=A0=81=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8CJPEG=20=E8=A7=A3=E7=A0=81=E6=9C=9F=E7=BC=A9?= =?UTF-8?q?=E8=A7=A3=EF=BC=88=E5=B3=B0=E5=80=BC=2043.7=E2=86=9214.0=20MiB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M6 finding(vuln/high):meow_img_load_file 必先整幅 RGBA 解码,1600×2400 漫画页 瞬时峰值 20.2 MiB(15.4 MiB 输出 + 5.5 MiB YCbCr 平面),超设备 ~14MB 口径, App 侧无法规避。 调研(vendored stb v2.30):无解码期缩放能力——JPEG 走固定 hs/vs 重采样到全尺寸, PNG 一次性 inflate 整幅 raw_image,公共 API 只有 load/load_16/loadf/info。 完整缩小 IDCT 需改编 1/2、1/4、1/8 IDCT + 进升级系数存储 + MCU 尺寸,改动面大且 易出静默像素错误,故不重写解码器。 改为受控的最小追加(sdk/stb_image.h): - 新增 stbi_load_scaled / stbi_load_scaled_from_memory:JPEG 输出级按步长 s 抽取 行/列(resample 状态机逐行推进,仅采样行做颜色转换),整幅原尺寸输出缓冲从不 物化;scale<=1 与旧 API 逐字节等价,非 JPEG 忽略。 - 进升级 JPEG:各分量系数 IDCT 进像素平面后立即释放(原来拖到输出分配之后)。 - 旧公共 API 行为不变。 SDK 追加式入口(旧 API 一字未改): - meow_img_load_file_dim / meow_img_load_mem_dim(path/buf, maxdim):stbi_info 头探测 选 1/2、1/4、1/8 整数档(取「长边/s ≥ maxdim」最大档,不欠采样),再交既有 meow_img_downscale 精修;小图/maxdim<=0 走旧路径。 - meow_img_probe_file:只读头取尺寸,供 App 在 OOM 前提示。 实测(宿主 test_meow_img_budget,--wrap malloc 打点,全部 PASS): 8.32MP JPEG 43.70 → 13.96 MiB(新/旧 0.32) 1600×2400 页 20.16 → 9.18 MiB progressive 43.70 → 35.83 MiB(系数阵列扫描期驻留,残余上限) PNG 2400×1600 25.65 → 25.65 MiB(stb 无 PNG 缩解,如实记录) BMP 2400×1600 14.65 → 15.23 MiB(同上,多一次降采样小缓冲) 缩解结果与「整幅解码+downscale」网格平均差 6.28;对 PIL 逐像素比对与全幅解码 同误差量级(mean ≤0.02, max ≤3)。 验证:build_sdk.sh 重编 meow_ui.a 0 warning;examples/hello 重链 0 warning; tuku 用 MEOW_SDK_DIR 指向本分支 sdk 重链 0 warning;既有 test_meow_img_color 仍全 PASS。 --- sdk/include/meow_ui_image.h | 12 + sdk/meow_ui_img.c | 59 ++++ sdk/stb_image.h | 92 ++++++- sdk/test_meow_img_budget.c | 464 ++++++++++++++++++++++++++++++++ sdk/test_meow_img_budget_gen.py | 69 +++++ 5 files changed, 684 insertions(+), 12 deletions(-) create mode 100644 sdk/test_meow_img_budget.c create mode 100644 sdk/test_meow_img_budget_gen.py diff --git a/sdk/include/meow_ui_image.h b/sdk/include/meow_ui_image.h index 36376b6..4427376 100644 --- a/sdk/include/meow_ui_image.h +++ b/sdk/include/meow_ui_image.h @@ -22,6 +22,18 @@ meow_img *meow_img_load_file(const char *path); /* 内部读文件 + stb 解 meow_img *meow_img_load_mem(const void *buf, int len); void meow_img_free(meow_img *img); +/* 追加(M12)预算解码:只读文件头取原始尺寸,不解码像素(零像素内存)。成功 1,失败 0。 + * App 可用它在加载前判断「图太大」并给用户提示,而不必先撞 OOM。 */ +int meow_img_probe_file(const char *path, int *w, int *h, int *channels); + +/* 追加(M12)预算解码:解码期受长边约束,返回图最长边 ≤ maxdim。 + * JPEG:按 1/2、1/4、1/8 整数步长抽取解码(stbi_load_scaled),整幅原尺寸 RGBA 缓冲从不物化, + * 峰值 ≈ 亮度分量平面 + 缩解输出 + 单行缓冲;随后用 meow_img_downscale 精修到 maxdim。 + * PNG/BMP 等 stb 无解码期缩放能力的格式:整幅解码后立即降采样(峰值同旧入口,见 M12 报告)。 + * 小图(长边 ≤ maxdim)走旧路径,与原 API 完全一致;maxdim<=0 等价旧入口。失败返回 NULL。 */ +meow_img *meow_img_load_file_dim(const char *path, int maxdim); +meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim); + int meow_img_w(const meow_img *img); int meow_img_h(const meow_img *img); /* 访问器:取 RGBA 像素指针(4 通道 straight alpha;供取色/直读像素)。meow_img 对外 opaque, diff --git a/sdk/meow_ui_img.c b/sdk/meow_ui_img.c index baf3791..9571800 100644 --- a/sdk/meow_ui_img.c +++ b/sdk/meow_ui_img.c @@ -42,6 +42,65 @@ meow_img *meow_img_load_file(const char *path) return img_alloc(w, h, p); } +/* 追加(M12):预算解码。选整数缩解档位 s ∈ {1,2,4,8}——取满足「长边/s ≥ maxdim」的最大档, + * 保证缩解结果不欠采样(不丢细节),再由 meow_img_downscale 精修到 maxdim。长边 < 2*maxdim + * 时无档可用(s=1),退回整幅解码后精修——此时本就接近目标尺寸,无需牺牲。 */ +static int img_pick_scale(int longside, int maxdim) +{ + int s = 1; + while (s < 8 && (longside / (s * 2)) >= maxdim) s *= 2; + return s; +} + +/* 追加(M12):缩解结果落地 + 需要时精修。p 所有权转交。 */ +static meow_img *img_finish_dim(unsigned char *p, int w, int h, int maxdim) +{ + if (!p) return NULL; + meow_img *im = img_alloc(w, h, p); + if (!im) return NULL; + if (im->w <= maxdim && im->h <= maxdim) return im; + meow_img *small = meow_img_downscale(im, maxdim); + if (!small) return im; /* 精修失败:缩解结果已小于原图,仍可用 */ + meow_img_free(im); + return small; +} + +int meow_img_probe_file(const char *path, int *w, int *h, int *channels) +{ + int pw = 0, ph = 0, pc = 0; + if (!path || !stbi_info(path, &pw, &ph, &pc)) return 0; + if (w) *w = pw; + if (h) *h = ph; + if (channels) *channels = pc; + return 1; +} + +meow_img *meow_img_load_file_dim(const char *path, int maxdim) +{ + if (!path) return NULL; + if (maxdim <= 0) return meow_img_load_file(path); /* 不限尺寸 = 旧入口 */ + int w = 0, h = 0, c = 0; + if (!stbi_info(path, &w, &h, &c)) return NULL; + int longside = w > h ? w : h; + if (longside <= maxdim) return meow_img_load_file(path); /* 小图不动:旧路径 */ + unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, img_pick_scale(longside, maxdim)); + return img_finish_dim(p, w, h, maxdim); +} + +meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim) +{ + if (!buf || len <= 0) return NULL; + if (maxdim <= 0) return meow_img_load_mem(buf, len); + int w = 0, h = 0, c = 0; + if (!stbi_info_from_memory((const unsigned char *)buf, len, &w, &h, &c)) return NULL; + int longside = w > h ? w : h; + if (longside <= maxdim) return meow_img_load_mem(buf, len); + unsigned char *p = stbi_load_scaled_from_memory((const unsigned char *)buf, len, + &w, &h, &c, 4, + img_pick_scale(longside, maxdim)); + return img_finish_dim(p, w, h, maxdim); +} + void meow_img_free(meow_img *im) { if (!im) return; diff --git a/sdk/stb_image.h b/sdk/stb_image.h index 9eedabe..e1ef827 100644 --- a/sdk/stb_image.h +++ b/sdk/stb_image.h @@ -429,6 +429,14 @@ STBIDEF stbi_uc *stbi_load_from_file (FILE *f, int *x, int *y, int *channels_in // for stbi_load_from_file, file pointer is left pointing immediately after image #endif +// 追加(MeowOS SDK M12):JPEG 解码期降采样。scale>=2 时按 s 抽取输出行/列, +// 不物化整幅原尺寸输出缓冲(*x,*y 返回 ceil(原尺寸/s))。scale<=1 与 +// stbi_load / stbi_load_from_memory 完全等价;非 JPEG 格式忽略 scale。 +STBIDEF stbi_uc *stbi_load_scaled_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels, int scale); +#ifndef STBI_NO_STDIO +STBIDEF stbi_uc *stbi_load_scaled (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels, int scale); +#endif + #ifndef STBI_NO_GIF STBIDEF stbi_uc *stbi_load_gif_from_memory(stbi_uc const *buffer, int len, int **delays, int *x, int *y, int *z, int *comp, int req_comp); #endif @@ -907,7 +915,7 @@ typedef struct #ifndef STBI_NO_JPEG static int stbi__jpeg_test(stbi__context *s); -static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static void *stbi__jpeg_load_ex(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int scale); static int stbi__jpeg_info(stbi__context *s, int *x, int *y, int *comp); #endif @@ -1134,7 +1142,7 @@ STBIDEF void stbi_set_flip_vertically_on_load_thread(int flag_true_if_should_fli : stbi__vertically_flip_on_load_global) #endif // STBI_THREAD_LOCAL -static void *stbi__load_main(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc) +static void *stbi__load_main(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc, int jpeg_scale) { memset(ri, 0, sizeof(*ri)); // make sure it's initialized if we add new fields ri->bits_per_channel = 8; // default is 8 so most paths don't have to be changed @@ -1165,7 +1173,9 @@ static void *stbi__load_main(stbi__context *s, int *x, int *y, int *comp, int re // bytes matching expectations; these are prone to false positives, so // try them later #ifndef STBI_NO_JPEG - if (stbi__jpeg_test(s)) return stbi__jpeg_load(s,x,y,comp,req_comp, ri); + if (stbi__jpeg_test(s)) return stbi__jpeg_load_ex(s,x,y,comp,req_comp, ri, jpeg_scale); + #else + STBI_NOTUSED(jpeg_scale); #endif #ifndef STBI_NO_PNM if (stbi__pnm_test(s)) return stbi__pnm_load(s,x,y,comp,req_comp, ri); @@ -1257,10 +1267,10 @@ static void stbi__vertical_flip_slices(void *image, int w, int h, int z, int byt } #endif -static unsigned char *stbi__load_and_postprocess_8bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) +static unsigned char *stbi__load_and_postprocess_8bit_ex(stbi__context *s, int *x, int *y, int *comp, int req_comp, int jpeg_scale) { stbi__result_info ri; - void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 8); + void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 8, jpeg_scale); if (result == NULL) return NULL; @@ -1283,10 +1293,15 @@ static unsigned char *stbi__load_and_postprocess_8bit(stbi__context *s, int *x, return (unsigned char *) result; } +static unsigned char *stbi__load_and_postprocess_8bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + return stbi__load_and_postprocess_8bit_ex(s, x, y, comp, req_comp, 1); +} + static stbi__uint16 *stbi__load_and_postprocess_16bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) { stbi__result_info ri; - void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 16); + void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 16, 1); if (result == NULL) return NULL; @@ -1409,6 +1424,19 @@ STBIDEF stbi_us *stbi_load_16(char const *filename, int *x, int *y, int *comp, i return result; } +// 追加(M12):JPEG 解码期降采样(见头文件声明)。非 JPEG 等价于 stbi_load。 +STBIDEF stbi_uc *stbi_load_scaled(char const *filename, int *x, int *y, int *comp, int req_comp, int scale) +{ + FILE *f = stbi__fopen(filename, "rb"); + stbi_uc *result; + stbi__context s; + if (!f) return stbi__errpuc("can't fopen", "Unable to open file"); + stbi__start_file(&s,f); + result = stbi__load_and_postprocess_8bit_ex(&s,x,y,comp,req_comp, scale); + if (result) fseek(f, - (int) (s.img_buffer_end - s.img_buffer), SEEK_CUR); + fclose(f); + return result; +} #endif //!STBI_NO_STDIO @@ -1433,6 +1461,14 @@ STBIDEF stbi_uc *stbi_load_from_memory(stbi_uc const *buffer, int len, int *x, i return stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); } +// 追加(M12):JPEG 解码期降采样(见头文件声明)。非 JPEG 等价于 stbi_load_from_memory。 +STBIDEF stbi_uc *stbi_load_scaled_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp, int req_comp, int scale) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__load_and_postprocess_8bit_ex(&s,x,y,comp,req_comp, scale); +} + STBIDEF stbi_uc *stbi_load_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp, int req_comp) { stbi__context s; @@ -1982,6 +2018,7 @@ typedef struct int nomore; // flag if we saw a marker so must stop int progressive; + int scale; // 追加(M12):输出抽取步长,默认 1=原尺寸 int spec_start; int spec_end; int succ_high; @@ -3092,6 +3129,12 @@ static void stbi__jpeg_finish(stbi__jpeg *z) z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*j*8+i*8, z->img_comp[n].w2, data); } } + // 追加(M12):该分量系数已全部 IDCT 进像素平面,立即释放,降低进升级 JPEG 的峰值 + if (z->img_comp[n].raw_coeff) { + STBI_FREE(z->img_comp[n].raw_coeff); + z->img_comp[n].raw_coeff = 0; + z->img_comp[n].coeff = 0; + } } } } @@ -3893,6 +3936,10 @@ static stbi_uc *load_jpeg_image(stbi__jpeg *z, int *out_x, int *out_y, int *comp unsigned int i,j; stbi_uc *output; stbi_uc *coutput[4] = { NULL, NULL, NULL, NULL }; + stbi_uc *rowbuf = NULL; // 追加(M12):整行临时缓冲,仅 dec>1 时使用 + int dec = z->scale > 1 ? z->scale : 1; + int out_w = (z->s->img_x + dec - 1) / dec; + int out_h = (z->s->img_y + dec - 1) / dec; stbi__resample res_comp[4]; @@ -3918,13 +3965,19 @@ static stbi_uc *load_jpeg_image(stbi__jpeg *z, int *out_x, int *out_y, int *comp else r->resample = stbi__resample_row_generic; } + // 追加(M12):dec>1 时整行先转成像素再按步长抽取,不物化整幅原尺寸输出缓冲 + if (dec > 1) { + rowbuf = (stbi_uc *) stbi__malloc_mad2(n, z->s->img_x, 0); + if (!rowbuf) { stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } + } + // can't error after this so, this is safe - output = (stbi_uc *) stbi__malloc_mad3(n, z->s->img_x, z->s->img_y, 1); - if (!output) { stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } + output = (stbi_uc *) stbi__malloc_mad3(n, out_w, out_h, 1); + if (!output) { STBI_FREE(rowbuf); stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } // now go ahead and resample for (j=0; j < z->s->img_y; ++j) { - stbi_uc *out = output + n * z->s->img_x * j; + stbi_uc *out; for (k=0; k < decode_n; ++k) { stbi__resample *r = &res_comp[k]; int y_bot = r->ystep >= (r->vs >> 1); @@ -3939,6 +3992,9 @@ static stbi_uc *load_jpeg_image(stbi__jpeg *z, int *out_x, int *out_y, int *comp r->line1 += z->img_comp[k].w2; } } + // 追加(M12):resample 状态机须逐行推进(上面已完成);非采样行直接丢弃 + if (dec > 1 && (j % (unsigned int) dec) != 0) continue; + out = (dec > 1) ? rowbuf : output + (size_t) n * out_w * j; if (n >= 3) { stbi_uc *y = coutput[0]; if (z->s->img_n == 3) { @@ -4016,16 +4072,27 @@ static stbi_uc *load_jpeg_image(stbi__jpeg *z, int *out_x, int *out_y, int *comp for (i=0; i < z->s->img_x; ++i) { *out++ = y[i]; *out++ = 255; } } } + // 追加(M12):rowbuf 已按整行转好像素,按步长抽取写入输出行 + if (dec > 1) { + stbi_uc *dst = output + (size_t) n * out_w * (j / (unsigned int) dec); + unsigned int x; + for (x=0; x < (unsigned int) out_w; ++x) { + int c; + for (c=0; c < n; ++c) + dst[(size_t) x * n + c] = rowbuf[(size_t) x * dec * n + c]; + } + } } + STBI_FREE(rowbuf); stbi__cleanup_jpeg(z); - *out_x = z->s->img_x; - *out_y = z->s->img_y; + *out_x = out_w; + *out_y = out_h; if (comp) *comp = z->s->img_n >= 3 ? 3 : 1; // report original components, not output return output; } } -static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +static void *stbi__jpeg_load_ex(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int scale) { unsigned char* result; stbi__jpeg* j = (stbi__jpeg*) stbi__malloc(sizeof(stbi__jpeg)); @@ -4033,6 +4100,7 @@ static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int re memset(j, 0, sizeof(stbi__jpeg)); STBI_NOTUSED(ri); j->s = s; + j->scale = scale; // 追加(M12):输出抽取步长 stbi__setup_jpeg(j); result = load_jpeg_image(j, x,y,comp,req_comp); STBI_FREE(j); diff --git a/sdk/test_meow_img_budget.c b/sdk/test_meow_img_budget.c new file mode 100644 index 0000000..275760a --- /dev/null +++ b/sdk/test_meow_img_budget.c @@ -0,0 +1,464 @@ +/* test_meow_img_budget.c — M12 预算解码(meow_img_load_file_dim / _mem_dim)宿主验证。 + * + * 覆盖矩阵(对齐 M12 任务 3/4): + * B1 探针:meow_img_probe_file 只读头取原始尺寸(8.32MP 图),坏图/不存在返回 0 + * B2 小图不动:64x64 走旧路径,与 meow_img_load_file 逐字节相同;maxdim<=0 等价旧入口 + * B3 长边约束:8.32MP baseline JPEG 在 maxdim=480/300/128/64 下结果两维都 ≤ maxdim,长宽比保持 + * B4 峰值对比:同一 8.32MP JPEG「旧入口 vs 新入口」瞬时分配峰值(--wrap malloc 打点), + * 断言新入口峰值 < 旧入口 1/2;B4b 极端 maxdim=100;B4c finding 原始场景 1600x2400 页 + * B5 内容正确:新入口结果与「旧入口整幅解码 + meow_img_downscale」网格采样比对(平均差阈值) + * B6 进升级:progressive JPEG 走同一缩解路径(峰值记录 + 长边约束) + * B7 内存入口:meow_img_load_mem_dim 与文件入口等价(峰值 < 旧内存入口 1/2) + * B8 坏图:坏 JPEG(魔数+垃圾)/ 空内存 / 不存在路径 → NULL(不崩) + * B9 PNG/BMP:尺寸约束成立,峰值如实记录(stb 无解码期缩放 → 与旧入口同级) + * + * 夹具(生成一次): + * python3 test_meow_img_budget_gen.py "$PREFIX/tmp/meow_img_budget" + * 编译(本 sdk 目录下;--wrap 用于给 malloc/free 家族打点): + * gcc -Wall -Wextra -I. -Iinclude -DMEOW_UI_HOST_TEST -o test_meow_img_budget \ + * test_meow_img_budget.c meow_ui_img.c stb_image.c -lm \ + * -Wl,--wrap=malloc -Wl,--wrap=calloc -Wl,--wrap=realloc -Wl,--wrap=free + * 运行:./test_meow_img_budget "$PREFIX/tmp/meow_img_budget" (exit 0=PASS / 1=FAIL) + * 夹具目录可省略:默认 $PREFIX/tmp/meow_img_budget;缺失时自动调 gen 脚本生成。 + */ +#define _GNU_SOURCE +#include "meow_ui.h" +#include "meow_ui_internal.h" /* meow_gfx_blit_rgba 原型(本测试只给桩) */ + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 分配打点:--wrap malloc/free 家族,记录「存活字节」与峰值 */ +/* ------------------------------------------------------------------ */ +#define AHDR 32 /* 头部长度:保留原始大小,保证 16 字节对齐 */ + +static size_t g_live = 0; +static size_t g_peak = 0; + +extern void *__real_malloc(size_t); +extern void *__real_calloc(size_t, size_t); +extern void *__real_realloc(void *, size_t); +extern void __real_free(void *); + +static void acc_alloc(size_t n) +{ + g_live += n; + if (g_live > g_peak) g_peak = g_live; +} + +void *__wrap_malloc(size_t n) +{ + size_t *h = (size_t *)__real_malloc(n + AHDR); + if (!h) return NULL; + h[0] = n; + acc_alloc(n); + return (char *)h + AHDR; +} + +void *__wrap_calloc(size_t a, size_t b) +{ + size_t n = a * b; + void *p = __wrap_malloc(n); + if (p) memset(p, 0, n); + return p; +} + +void __wrap_free(void *p) +{ + size_t *h; + if (!p) return; + h = (size_t *)((char *)p - AHDR); + g_live -= h[0]; + __real_free(h); +} + +void *__wrap_realloc(void *p, size_t n) +{ + size_t old = p ? ((size_t *)((char *)p - AHDR))[0] : 0; + void *q = __wrap_malloc(n); + if (!q) return NULL; + if (p) { + memcpy(q, p, old < n ? old : n); + __wrap_free(p); + } + return q; +} + +static size_t peak_reset(void) { g_peak = g_live; return g_live; } +static size_t peak_since(size_t base) { return g_peak - base; } + +/* meow_ui_img.c 的绘制函数需要的 gfx 符号:本测试只测解码/内存,桩即可。 */ +void meow_gfx_blit_rgba(int x, int y, int w, int h, const uint8_t *rgba, int alpha) +{ + (void)x; (void)y; (void)w; (void)h; (void)rgba; (void)alpha; +} + +/* ------------------------------------------------------------------ */ +/* 断言与打印 */ +/* ------------------------------------------------------------------ */ +static int g_fail = 0; +#define CHECK(cond, name) do { \ + if (cond) printf(" PASS %s\n", name); \ + else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fail++; } } while (0) + +static void peak_report(const char *tag, size_t bytes) +{ + printf(" peak %-28s %8.2f MiB (%zu B)\n", tag, (double)bytes / (1024.0 * 1024.0), bytes); +} + +/* ------------------------------------------------------------------ */ +/* 路径与夹具 */ +/* ------------------------------------------------------------------ */ +static const char *g_dir = NULL; + +static char *path_join(const char *name) +{ + size_t n = strlen(g_dir) + strlen(name) + 2; + char *p = (char *)malloc(n); + if (!p) { printf("OOM\n"); exit(1); } + snprintf(p, n, "%s/%s", g_dir, name); + return p; +} + +static int file_exists(const char *p) +{ + struct stat st; + return stat(p, &st) == 0 && st.st_size > 0; +} + +static void ensure_fixtures(void) +{ + char *big = path_join("big.jpg"); + if (file_exists(big)) { free(big); return; } + free(big); + printf("夹具缺失,尝试 python3 test_meow_img_budget_gen.py %s\n", g_dir); + { + char cmd[1024]; + snprintf(cmd, sizeof cmd, "python3 test_meow_img_budget_gen.py '%s'", g_dir); + if (system(cmd) != 0) + printf(" 生成失败(缺 Pillow?)——请按头注释手动生成\n"); + } +} + +static uint8_t *read_whole(const char *p, size_t *len) +{ + FILE *f = fopen(p, "rb"); + uint8_t *buf; + long n; + if (!f) return NULL; + fseek(f, 0, SEEK_END); + n = ftell(f); + fseek(f, 0, SEEK_SET); + if (n <= 0) { fclose(f); return NULL; } + buf = (uint8_t *)malloc((size_t)n); + if (!buf) { fclose(f); return NULL; } + if (fread(buf, 1, (size_t)n, f) != (size_t)n) { free(buf); fclose(f); return NULL; } + fclose(f); + *len = (size_t)n; + return buf; +} + +/* ------------------------------------------------------------------ */ +/* 图像辅助 */ +/* ------------------------------------------------------------------ */ +static int nonblank(const meow_img *im) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *p = meow_img_pixels(im); + long sum = 0; + if (!p || w <= 0 || h <= 0) return 0; + for (long i = 0; i < (long)w * h; i += 97) + sum += p[i * 4] + p[i * 4 + 1] + p[i * 4 + 2]; + return sum > 0; +} + +/* 按比例网格采样比对两图(尺寸可不同):返回各通道平均绝对差。 */ +static double mean_abs_diff(const meow_img *a, const meow_img *b, int grid) +{ + int wa = meow_img_w(a), ha = meow_img_h(a); + int wb = meow_img_w(b), hb = meow_img_h(b); + const unsigned char *pa = meow_img_pixels(a), *pb = meow_img_pixels(b); + long acc = 0, cnt = 0; + if (!pa || !pb || wa <= 0 || hb <= 0) return 1e9; + for (int gy = 0; gy < grid; gy++) { + int ya = (int)((long)gy * ha / grid); + int yb = (int)((long)gy * hb / grid); + for (int gx = 0; gx < grid; gx++) { + int xa = (int)((long)gx * wa / grid); + int xb = (int)((long)gx * wb / grid); + const unsigned char *qa = pa + ((size_t)ya * wa + xa) * 4; + const unsigned char *qb = pb + ((size_t)yb * wb + xb) * 4; + for (int k = 0; k < 3; k++) { + int d = (int)qa[k] - (int)qb[k]; + acc += d < 0 ? -d : d; + cnt++; + } + } + } + return cnt ? (double)acc / (double)cnt : 1e9; +} + +/* ================================================================== */ +int main(int argc, char **argv) +{ + char *p_big, *p_prog, *p_small, *p_png, *p_bmp, *p_broken, *p_missing; + size_t peak_old, peak_new; + + if (argc > 1) { + g_dir = argv[1]; + } else { + /* Termux 的 /tmp 不可写:优先 $TMPDIR,其次 $PREFIX/tmp。 */ + static char dflt[600]; + const char *tmp = getenv("TMPDIR"); + const char *pre = getenv("PREFIX"); + if (tmp && *tmp) snprintf(dflt, sizeof dflt, "%s/meow_img_budget", tmp); + else if (pre && *pre) snprintf(dflt, sizeof dflt, "%s/tmp/meow_img_budget", pre); + else snprintf(dflt, sizeof dflt, "/tmp/meow_img_budget"); + g_dir = dflt; + } + ensure_fixtures(); + + p_big = path_join("big.jpg"); + p_prog = path_join("bigprog.jpg"); + p_small = path_join("small.jpg"); + p_png = path_join("big.png"); + p_bmp = path_join("big.bmp"); + p_broken = path_join("broken.jpg"); + p_missing = path_join("no_such_file.jpg"); + + if (!file_exists(p_big)) { + printf("FATAL 夹具 %s 不存在(先跑 test_meow_img_budget_gen.py)\n", p_big); + return 1; + } + + /* ---- B1 探针 ---- */ + printf("[B1] 头探测(零像素内存)\n"); + { + int w = 0, h = 0, c = 0; + CHECK(meow_img_probe_file(p_big, &w, &h, &c) == 1, "probe 成功"); + CHECK(w == 3200 && h == 2600, "probe 原始尺寸 3200x2600"); + CHECK(c == 3, "probe 通道数 3"); + printf(" probe → %dx%d ch=%d\n", w, h, c); + CHECK(meow_img_probe_file(p_broken, NULL, NULL, NULL) == 0, "probe 截断图 → 0"); + CHECK(meow_img_probe_file(p_missing, NULL, NULL, NULL) == 0, "probe 不存在 → 0"); + CHECK(meow_img_probe_file(NULL, NULL, NULL, NULL) == 0, "probe NULL → 0"); + } + + /* ---- B2 小图不动 ---- */ + printf("[B2] 小图不动 / maxdim<=0 等价旧入口\n"); + { + meow_img *a = meow_img_load_file(p_small); + meow_img *b = meow_img_load_file_dim(p_small, 64); + meow_img *z = meow_img_load_file_dim(p_small, 0); + CHECK(a && b && z, "小图三条路径都成功"); + CHECK(meow_img_w(b) == meow_img_w(a) && meow_img_h(b) == meow_img_h(a), + "maxdim=64 → 尺寸不变"); + CHECK(b && a && meow_img_w(a) * meow_img_h(a) > 0 && + memcmp(meow_img_pixels(a), meow_img_pixels(b), + (size_t)meow_img_w(a) * meow_img_h(a) * 4) == 0, + "小图逐字节与旧入口一致"); + CHECK(z && a && memcmp(meow_img_pixels(a), meow_img_pixels(z), + (size_t)meow_img_w(a) * meow_img_h(a) * 4) == 0, + "maxdim=0 等价旧入口(逐字节)"); + meow_img_free(a); meow_img_free(b); meow_img_free(z); + } + + /* ---- B3 长边约束 ---- */ + printf("[B3] 8.32MP baseline JPEG 长边约束\n"); + { + static const int dims[] = { 480, 300, 128, 64 }; + int wi = 0, hi = 0; + meow_img *full = meow_img_load_file(p_big); + CHECK(full != NULL, "旧入口解码成功"); + if (full) { wi = meow_img_w(full); hi = meow_img_h(full); } + printf(" 原图 %dx%d (%.2f MP)\n", wi, hi, (double)wi * hi / 1e6); + for (size_t i = 0; i < sizeof dims / sizeof dims[0]; i++) { + meow_img *s = meow_img_load_file_dim(p_big, dims[i]); + char name[96]; + snprintf(name, sizeof name, "maxdim=%d → 两维 ≤ maxdim 且非空", dims[i]); + CHECK(s && meow_img_w(s) <= dims[i] && meow_img_h(s) <= dims[i] && nonblank(s), name); + if (s) { + double ar0 = (double)wi / hi, ar1 = (double)meow_img_w(s) / meow_img_h(s); + CHECK(ar1 > ar0 * 0.98 && ar1 < ar0 * 1.02, "长宽比保持(±2%)"); + printf(" maxdim=%-4d → %dx%d\n", dims[i], meow_img_w(s), meow_img_h(s)); + } + meow_img_free(s); + } + meow_img_free(full); + } + + /* ---- B4 峰值对比(JPEG) ---- */ + printf("[B4] 瞬时分配峰值:旧入口 vs 新入口(同一 8.32MP JPEG)\n"); + { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(p_big); + peak_old = peak_since(base); + + base = peak_reset(); + meow_img *nw = meow_img_load_file_dim(p_big, 480); + peak_new = peak_since(base); + + CHECK(old && nw, "两条路径都成功"); + peak_report("旧入口 meow_img_load_file", peak_old); + peak_report("新入口 _dim(480)", peak_new); + printf(" 整幅 RGBA 理论值 = %.2f MiB;新/旧 = %.2f\n", + (double)meow_img_w(old) * meow_img_h(old) * 4 / (1024.0 * 1024.0), + (double)peak_new / (double)peak_old); + CHECK(peak_new * 2 < peak_old, "新入口峰值 < 旧入口 1/2(不物化整幅 RGBA)"); + CHECK(peak_new < (size_t)20 * 1024 * 1024, "新入口峰值 < 20 MiB(8.32MP 页可加载)"); + + /* B5 内容正确性:新 vs 旧整幅 + downscale 精修 */ + if (old && nw) { + meow_img *ref = meow_img_downscale(old, 480); + double d = mean_abs_diff(nw, ref ? ref : old, 64); + printf(" 与「旧入口+downscale」网格平均差 = %.2f\n", d); + CHECK(d < 20.0, "缩解内容结构一致(平均差 < 20)"); + meow_img_free(ref); + } + meow_img_free(old); + meow_img_free(nw); + } + + /* ---- B4b 极端 maxdim ---- */ + printf("[B4b] maxdim=100(8 倍缩解档位 + 精修)\n"); + { + size_t base = peak_reset(); + meow_img *s = meow_img_load_file_dim(p_big, 100); + size_t peak = peak_since(base); + CHECK(s && meow_img_w(s) <= 100 && meow_img_h(s) <= 100 && nonblank(s), "结果 ≤100 且非空"); + peak_report("_dim(100)", peak); + CHECK(peak * 2 < peak_old, "峰值仍 < 旧入口 1/2"); + meow_img_free(s); + } + + /* ---- B4c finding 原始场景 ---- */ + printf("[B4c] finding 原始场景:1600x2400 漫画页\n"); + { + char *p_page = path_join("page.jpg"); + if (file_exists(p_page)) { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(p_page); + size_t p_old = peak_since(base); + base = peak_reset(); + meow_img *nw = meow_img_load_file_dim(p_page, 720); + size_t p_new = peak_since(base); + CHECK(old && nw, "1600x2400 两条路径都成功"); + if (nw) + CHECK(meow_img_w(nw) <= 720 && meow_img_h(nw) <= 720 && nonblank(nw), + "结果 ≤720 且非空"); + if (old) + printf(" 整幅 RGBA 理论值 = %.2f MiB\n", + (double)meow_img_w(old) * meow_img_h(old) * 4 / (1024.0 * 1024.0)); + peak_report("旧入口 page.jpg", p_old); + peak_report("新入口 _dim(720)", p_new); + CHECK(p_new < (size_t)10 * 1024 * 1024, "新入口峰值 < 10 MiB(设备 ~14MB 口径内)"); + meow_img_free(old); + meow_img_free(nw); + } else { + printf(" SKIP page.jpg(夹具缺失)\n"); + } + free(p_page); + } + + /* ---- B6 进升级 JPEG ---- */ + printf("[B6] progressive JPEG(同一缩解路径)\n"); + { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(p_prog); + size_t p_old = peak_since(base); + base = peak_reset(); + meow_img *nw = meow_img_load_file_dim(p_prog, 480); + size_t p_new = peak_since(base); + CHECK(old && nw, "进升级两条路径都成功"); + CHECK(nw && meow_img_w(nw) <= 480 && meow_img_h(nw) <= 480 && nonblank(nw), + "进升级结果 ≤480 且非空"); + peak_report("旧入口 progressive", p_old); + peak_report("新入口 _dim(480) progressive", p_new); + CHECK(p_new < p_old, "进升级峰值下降(系数阵列仍在扫描期驻留,见报告)"); + meow_img_free(old); + meow_img_free(nw); + } + + /* ---- B7 内存入口 ---- */ + printf("[B7] 内存入口 meow_img_load_mem_dim(CBZ/档案页场景)\n"); + { + size_t len = 0; + uint8_t *buf = read_whole(p_big, &len); + CHECK(buf != NULL, "读入 8.32MP JPEG 到内存"); + if (buf) { + size_t base = peak_reset(); + meow_img *old = meow_img_load_mem(buf, (int)len); + size_t p_old = peak_since(base); + base = peak_reset(); + meow_img *nw = meow_img_load_mem_dim(buf, (int)len, 480); + size_t p_new = peak_since(base); + CHECK(old && nw, "内存入口两条路径都成功"); + CHECK(nw && meow_img_w(nw) <= 480 && nonblank(nw), "内存入口结果 ≤480 且非空"); + peak_report("旧 meow_img_load_mem", p_old); + peak_report("新 meow_img_load_mem_dim", p_new); + CHECK(p_new * 2 < p_old, "内存入口峰值 < 旧入口 1/2"); + meow_img_free(old); + meow_img_free(nw); + free(buf); + } + } + + /* ---- B8 坏图 ---- */ + printf("[B8] 坏图容错\n"); + { + size_t len = 0; + uint8_t *buf; + CHECK(meow_img_load_file_dim(p_broken, 480) == NULL, "截断 JPEG → NULL"); + CHECK(meow_img_load_file_dim(p_missing, 480) == NULL, "不存在路径 → NULL"); + CHECK(meow_img_load_file_dim(NULL, 480) == NULL, "NULL 路径 → NULL"); + buf = read_whole(p_broken, &len); + CHECK(buf && meow_img_load_mem_dim(buf, (int)len, 480) == NULL, "截断 JPEG 内存入口 → NULL"); + free(buf); + CHECK(meow_img_load_mem_dim("x", 3, 480) == NULL, "垃圾内存 → NULL"); + } + + /* ---- B9 PNG / BMP(stb 无解码期缩放,如实记录峰值) ---- */ + printf("[B9] PNG / BMP:尺寸约束成立,峰值如实记录(stb 无解码期缩放)\n"); + { + struct { const char *tag; const char *path; } fx[] = { + { "PNG", p_png }, { "BMP", p_bmp }, + }; + for (size_t i = 0; i < sizeof fx / sizeof fx[0]; i++) { + if (!file_exists(fx[i].path)) { + printf(" SKIP %s(夹具缺失)\n", fx[i].tag); + continue; + } + { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(fx[i].path); + size_t p_old = peak_since(base); + meow_img *nw; + char name[96]; + base = peak_reset(); + nw = meow_img_load_file_dim(fx[i].path, 480); + { + size_t p_new = peak_since(base); + snprintf(name, sizeof name, "%s _dim(480) 两维 ≤480 且非空", fx[i].tag); + CHECK(nw && meow_img_w(nw) <= 480 && meow_img_h(nw) <= 480 && nonblank(nw), name); + peak_report(fx[i].tag, p_old); + peak_report(" ↑ 新入口", p_new); + snprintf(name, sizeof name, "%s 峰值与旧入口同级(stb 无解码期缩放)", fx[i].tag); + CHECK(p_new <= p_old + p_old / 8, name); + if (old) printf(" %s 原图 %dx%d\n", fx[i].tag, meow_img_w(old), meow_img_h(old)); + meow_img_free(nw); + } + meow_img_free(old); + } + } + } + + if (g_fail) printf("\nRESULT: FAIL (%d)\n", g_fail); + else printf("\nRESULT: ALL PASS\n"); + free(p_big); free(p_prog); free(p_small); free(p_png); + free(p_bmp); free(p_broken); free(p_missing); + return g_fail ? 1 : 0; +} diff --git a/sdk/test_meow_img_budget_gen.py b/sdk/test_meow_img_budget_gen.py new file mode 100644 index 0000000..715c6ad --- /dev/null +++ b/sdk/test_meow_img_budget_gen.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""test_meow_img_budget_gen.py — 为宿主测试 test_meow_img_budget.c 生成图片夹具。 + +用法:python3 test_meow_img_budget_gen.py <输出目录> +依赖:Pillow(PIL)。夹具只含结构化图案(白底/黑线/灰块),体积极小。 + +生成: + big.jpg 3200x2600 baseline 4:2:0 JPEG(8.32MP,峰值对比主用例) + bigprog.jpg 3200x2600 progressive JPEG(验证进升级解码走同一缩解路径) + page.jpg 1600x2400 baseline JPEG(finding 里 15.4MB 的漫画页原尺寸) + small.jpg 64x64 baseline JPEG(「小图不动」用例) + big.png 2400x1600 RGB PNG(stb 无解码期缩放:峰值如实记录,不作改善断言) + big.bmp 2400x1600 24bpp BMP(同上) + broken.jpg JPEG 魔数 + 垃圾字节(头部解析失败,坏图用例) +""" +import os +import sys + +from PIL import Image, ImageDraw + + +def page_pattern(w, h): + """漫画页近似:白底 + 分镜框线 + 灰阶网点带。内容稀疏 → JPEG 体积小。""" + im = Image.new("RGB", (w, h), (250, 249, 245)) + d = ImageDraw.Draw(im) + for k in range(0, min(w, h) - 400, 60): + d.rectangle([100 + k, 100 + k, 400 + k, 300 + k], outline=(12, 12, 14), width=5) + for band in range(14): + y0 = band * h // 14 + for x in range(0, w, 9): + d.line([(x, y0), (x + 4, y0 + 7)], fill=(110, 110, 116)) + return im + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + return 2 + out = sys.argv[1] + os.makedirs(out, exist_ok=True) + + big = page_pattern(3200, 2600) + big.save(os.path.join(out, "big.jpg"), "JPEG", quality=55, subsampling=2) + big.save(os.path.join(out, "bigprog.jpg"), "JPEG", quality=55, subsampling=2, + progressive=True) + + small = page_pattern(64, 64) + small.save(os.path.join(out, "small.jpg"), "JPEG", quality=80, subsampling=2) + + page = page_pattern(1600, 2400) + page.save(os.path.join(out, "page.jpg"), "JPEG", quality=55, subsampling=2) + + mid = page_pattern(2400, 1600) + mid.save(os.path.join(out, "big.png"), "PNG") + mid.save(os.path.join(out, "big.bmp"), "BMP") + + # 坏图:JPEG 魔数 + 垃圾字节 → 头部解析失败(stb 对「截断但头部完整」很宽容,不能只截断) + garbage = bytes((i * 37 + 11) & 0xFF for i in range(256)) + with open(os.path.join(out, "broken.jpg"), "wb") as f: + f.write(b"\xff\xd8\xff\xe0" + garbage) + + for name in ("big.jpg", "bigprog.jpg", "page.jpg", "small.jpg", "big.png", + "big.bmp", "broken.jpg"): + print(" %-12s %8d B" % (name, os.path.getsize(os.path.join(out, name)))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From cca3906c8052119c1ce31eccc6579690304733bb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 21:24:18 +0800 Subject: [PATCH 07/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20M5=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=EF=BC=88=E9=98=85=E8=AF=BB=E6=96=B9=E5=90=91?= =?UTF-8?q?/PIN/=E4=B8=BB=E9=A2=98=E7=8A=B6=E6=80=81=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=20PIN=20=E9=94=81=E5=B1=8F=EF=BC=9B=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E6=96=B9=E5=90=91=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=88?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logic/pin(纯 C):加盐迭代哈希(FNV-1a ×1000 轮 + 每次设置新随机盐,16 位 hex)经 meow_config_* 持久化 pin.enabled/pin.hash/pin.salt;提供 set/verify/change/disable 与 4-8 位纯数字校验;enabled=1 但盐/哈希残缺时 fail-closed;落盘半途失败会还原旧盐,不留 「新盐+旧哈希」的双失效状态。 ui/settings(ACT_SETTINGS):MD3 分组表单(meow_listview 容器圆角卡 + 段头标签)—— 阅读方向 RTL/LTR 走 mr_reader_set_direction;启动 PIN 开关与修改(开启/修改均两遍 MEOW_IME_NUM 确认);版本与主题状态行(跟随系统 + 当前明暗,随 app.c 主题 listener 整帧重绘)。控件一律在 on_enter 建(栈顶=ACT_SETTINGS),避免 §6.1 owner 绑到 ACT_LIBRARY 的跨页命中泄漏。 ui/lock(ACT_LOCK):lock_view_should_show() 读 logic/pin;启用时启动先入锁屏,验证前 不绘制任何书库内容;「输入 PIN」按钮弹系统数字键盘(MEOW_IME_NUM),错 PIN 就地提示 重试(无惩罚性锁定),正确则 page_pop 回书库;on_event 消费 SWIPE——左缘返回不能绕过 PIN 或退出 App(meow_view_dispatch_event 先于 app.c 的 on_swipe)。 logic/reader(评审 P1 修复,最小持久化):mr_reader_set_direction 成功切换后 best-effort 写 meow_config("reader.dir","rtl"/"ltr"),写失败只打 WARN 不阻断本次切换; mr_reader_get_direction 首次调用惰性读一次配置恢复(无配置/非法值 → 默认 RTL),之后 纯内存快路径。宿主测试经 -DMR_READER_TEST_HOOKS 的 mr_reader_test_reset() 模拟重启。 测试/构建:test_pin(85 项,PIN 全分支,含单键半途落盘失败);test_settings_reader_persist (19 项:默认/落盘/重启恢复/坏值/非法值/只读一次/写失败只告警);可选宿主集成 test_settings_lock_host(真实 SDK view/activity/widget + IME 注入,端到端验证错/对 PIN 解锁与设置页两遍开启;SDK 无源码时 SKIP);run_tests.sh 保持 46/46 并纳入上述测试 (reader.c 的配置/日志依赖由 test/settings_reader_stub.c 存根),build_tuku.sh 加入 logic/pin.c。交叉构建静态 MIPS ELF(zig-mipsel)零警告。 安全口径:PIN 防随手翻看,不是安全边界——TF 卡可拔出直读、配置可改;存储不使用加密库, 锁屏与设置页文案均如实标注该局限。 --- tuku/build_tuku.sh | 2 +- tuku/logic/pin.c | 211 +++++++++++ tuku/logic/pin.h | 62 ++++ tuku/logic/reader.c | 51 ++- tuku/test/run_tests.sh | 51 ++- tuku/test/settings_reader_stub.c | 92 +++++ tuku/test/settings_reader_stub.h | 24 ++ tuku/test/test_pin.c | 320 ++++++++++++++++ tuku/test/test_settings_lock_host.c | 229 ++++++++++++ tuku/test/test_settings_reader_persist.c | 131 +++++++ tuku/ui/lock/lock.c | 194 ++++++++-- tuku/ui/settings/settings.c | 442 +++++++++++++++++++++-- 12 files changed, 1749 insertions(+), 60 deletions(-) create mode 100644 tuku/logic/pin.c create mode 100644 tuku/logic/pin.h create mode 100644 tuku/test/settings_reader_stub.c create mode 100644 tuku/test/settings_reader_stub.h create mode 100644 tuku/test/test_pin.c create mode 100644 tuku/test/test_settings_lock_host.c create mode 100644 tuku/test/test_settings_reader_persist.c diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index bccc665..602ceec 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -12,7 +12,7 @@ SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" "$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ - logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c logic/pin.c \ "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser ls -la picbrowser file picbrowser diff --git a/tuku/logic/pin.c b/tuku/logic/pin.c new file mode 100644 index 0000000..da43786 --- /dev/null +++ b/tuku/logic/pin.c @@ -0,0 +1,211 @@ +/* logic/pin.c — APP 启动 PIN:加盐迭代哈希 + meow_config 持久化(纯 C)。 + * + * 设计取舍(见 pin.h 头注释):PIN 防随手翻看,不是安全边界。本文件不引入加密库, + * 只做「每次设置新随机盐 + FNV-1a 迭代 1000 轮」的轻量哈希,避免明文 PIN 直接躺在 + * 配置里。配置落 TF 数据目录,卡被拔走即可读/改——这是产品口径接受的局限。 + * + * 依赖面:只 include include/meow_ui_config.h(get/set/remove/int 声明),不 include + * meow_ui.h、不碰 UI,宿主 gcc 可直接编译 + 测试存根替换配置层。 + */ +#include "pin.h" +#include "include/meow_ui_config.h" + +#include +#include +#include +#include +#include +#include + +#define PIN_KEY_ENABLED "pin.enabled" +#define PIN_KEY_HASH "pin.hash" +#define PIN_KEY_SALT "pin.salt" + +#define PIN_HEX_LEN 16 /* 64-bit 摘要/盐的 hex 字符数 */ +#define PIN_HEX_BUF 17 /* 16 + NUL */ +#define PIN_ROUNDS 1000 /* 迭代轮数(防直接反查,不防暴力枚举) */ +#define PIN_SALT_BYTES 8 /* 8 字节盐 → 16 位 hex */ + +/* ---------- 哈希 ---------- */ + +/* FNV-1a(64-bit)逐段吸收,返回新状态。 */ +static uint64_t fnv1a_update(uint64_t h, const void *data, size_t n) +{ + const unsigned char *p = (const unsigned char *)data; + for (size_t i = 0; i < n; i++) { + h ^= (uint64_t)p[i]; + h *= 1099511628211ULL; + } + return h; +} + +/* pin → 16 位 hex 摘要:FNV-1a(salt, pin) 起手,再做 PIN_ROUNDS 轮 + * 「上一轮摘要(大端 8 字节)+ 盐」的 FNV-1a。同一 PIN 每次设置因盐不同而摘要不同。 */ +static void pin_digest(const char *pin, const char *salt, char out[PIN_HEX_BUF]) +{ + uint64_t h = 1469598103934665603ULL; /* FNV-1a 64-bit offset basis */ + h = fnv1a_update(h, salt, strlen(salt)); + h = fnv1a_update(h, pin, strlen(pin)); + for (int r = 0; r < PIN_ROUNDS; r++) { + unsigned char be[8]; + for (int i = 0; i < 8; i++) + be[i] = (unsigned char)((h >> (8 * (7 - i))) & 0xFF); + h = fnv1a_update(h, be, sizeof be); + h = fnv1a_update(h, salt, strlen(salt)); + } + snprintf(out, PIN_HEX_BUF, "%016llx", (unsigned long long)h); +} + +/* 生成 16 位 hex 随机盐:优先 /dev/urandom;无设备时用时间/pid/地址/计数器做降级熵 + * (宿主测试与裁剪系统仍可用——降级盐的强度如实标注:不构成密码学随机)。 */ +static void pin_gen_salt(char out[PIN_HEX_BUF]) +{ + unsigned char b[PIN_SALT_BYTES]; + int ok = 0; + + FILE *f = fopen("/dev/urandom", "rb"); + if (f) { + ok = (fread(b, 1, sizeof b, f) == sizeof b); + fclose(f); + } + if (!ok) { + static uint64_t ctr; + uint64_t x = (uint64_t)time(NULL) + ^ ((uint64_t)clock() << 21) + ^ ((uint64_t)getpid() << 40) + ^ (uint64_t)(uintptr_t)&b + ^ (++ctr * 0x9E3779B97F4A7C15ULL); + for (int i = 0; i < PIN_SALT_BYTES; i++) + b[i] = (unsigned char)((x >> (8 * i)) & 0xFF); + } + + for (int i = 0; i < PIN_SALT_BYTES; i++) + snprintf(out + i * 2, 3, "%02x", b[i]); +} + +/* ---------- 配置层小包装 ---------- */ +static int cfg_get(const char *key, char *out, int outsz) +{ + return meow_config_get(key, out, outsz) == MEOW_CFG_OK ? 0 : -1; +} + +static int cfg_set(const char *key, const char *val) +{ + return meow_config_set(key, val) == MEOW_CFG_OK ? 0 : -1; +} + +static int is_hex16(const char *s) +{ + if (!s) return 0; + for (int i = 0; i < PIN_HEX_LEN; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return 0; + } + return s[PIN_HEX_LEN] == '\0'; +} + +/* ---------- 公共 API ---------- */ + +int mr_pin_valid(const char *pin) +{ + if (!pin) return 0; + int n = 0; + while (pin[n]) { + if (pin[n] < '0' || pin[n] > '9') return 0; + n++; + if (n > MR_PIN_MAX_LEN) return 0; + } + return n >= MR_PIN_MIN_LEN; +} + +int mr_pin_enabled(void) +{ + char hash[PIN_HEX_BUF + 8]; + char salt[PIN_HEX_BUF + 8]; + + if (meow_config_int(PIN_KEY_ENABLED, 0) != 1) return 0; + if (cfg_get(PIN_KEY_HASH, hash, (int)sizeof hash) != 0) return 0; + if (cfg_get(PIN_KEY_SALT, salt, (int)sizeof salt) != 0) return 0; + return is_hex16(hash) && is_hex16(salt); /* 配置残缺 → fail-closed */ +} + +int mr_pin_set(const char *pin) +{ + char salt[PIN_HEX_BUF]; + char hash[PIN_HEX_BUF]; + char old_salt[PIN_HEX_BUF + 8]; + int had_old_salt; + + if (!mr_pin_valid(pin)) return MR_PIN_ERR_FORMAT; + + pin_gen_salt(salt); + pin_digest(pin, salt, hash); + had_old_salt = (cfg_get(PIN_KEY_SALT, old_salt, (int)sizeof old_salt) == 0); + + /* 先写盐与摘要、最后打开 enabled:中途失败只会留下「未启用」,不会出现 + * enabled=1 但缺少盐/哈希的半残状态。摘要写失败时把盐还原成旧值,使已启用的旧 + * PIN 仍可校验(否则「新盐+旧哈希」会让新旧 PIN 都验不过)。 */ + if (cfg_set(PIN_KEY_SALT, salt) != 0) return MR_PIN_ERR_STORE; + if (cfg_set(PIN_KEY_HASH, hash) != 0) { + if (had_old_salt) cfg_set(PIN_KEY_SALT, old_salt); + else meow_config_remove(PIN_KEY_SALT); + return MR_PIN_ERR_STORE; + } + if (cfg_set(PIN_KEY_ENABLED, "1") != 0) { + /* 重设时 enabled 本来就是 "1":新盐/哈希已一致生效,按成功返回; + * 首次开启时 enabled 仍为 0 → 如实报落盘失败。 */ + return meow_config_int(PIN_KEY_ENABLED, 0) == 1 ? MR_PIN_OK : MR_PIN_ERR_STORE; + } + return MR_PIN_OK; +} + +int mr_pin_verify(const char *pin) +{ + char hash[PIN_HEX_BUF + 8]; + char salt[PIN_HEX_BUF + 8]; + char calc[PIN_HEX_BUF]; + + if (!mr_pin_valid(pin)) return 0; + if (!mr_pin_enabled()) return 0; + if (cfg_get(PIN_KEY_HASH, hash, (int)sizeof hash) != 0) return 0; + if (cfg_get(PIN_KEY_SALT, salt, (int)sizeof salt) != 0) return 0; + + pin_digest(pin, salt, calc); + unsigned diff = 0; + for (int i = 0; i < PIN_HEX_LEN; i++) + diff |= (unsigned)((unsigned char)hash[i] ^ (unsigned char)calc[i]); + diff |= (unsigned)(hash[PIN_HEX_LEN] != '\0'); + return diff == 0; +} + +int mr_pin_change(const char *old_pin, const char *new_pin) +{ + if (!mr_pin_enabled()) return MR_PIN_ERR_STATE; + if (!mr_pin_valid(new_pin)) return MR_PIN_ERR_FORMAT; + if (!mr_pin_verify(old_pin)) return MR_PIN_ERR_WRONG; + return mr_pin_set(new_pin); +} + +int mr_pin_disable(void) +{ + meow_config_remove(PIN_KEY_HASH); + meow_config_remove(PIN_KEY_SALT); + if (cfg_set(PIN_KEY_ENABLED, "0") != 0) return MR_PIN_ERR_STORE; + return MR_PIN_OK; +} + +int mr_pin_get_salt(char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + if (cfg_get(PIN_KEY_SALT, out, outsz) != 0) return 0; + return out[0] != '\0'; +} + +int mr_pin_get_hash(char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + if (cfg_get(PIN_KEY_HASH, out, outsz) != 0) return 0; + return out[0] != '\0'; +} diff --git a/tuku/logic/pin.h b/tuku/logic/pin.h new file mode 100644 index 0000000..af3fc47 --- /dev/null +++ b/tuku/logic/pin.h @@ -0,0 +1,62 @@ +/* logic/pin.h — APP 启动 PIN(纯 C;宿主可裸测)。 + * + * 定位(诚实标注):PIN 只挡「随手翻看」——TF 卡可拔出直读,数据目录明文可改, + * 故不引入加密库、不宣称安全边界。存储口径:加盐迭代哈希(FNV-1a ×1000 轮, + * 每次设置新随机盐,hex 输出),落在 meow_config_*(App 自有 KV → TF MeowData)。 + * 盐/哈希只做「不知道 PIN 无法直接重算」的轻量门槛,防不了离线暴力或改配置。 + * + * 配置键(meow_config_*,值均为字符串): + * pin.enabled = "1"/"0" + * pin.hash = 16 位 hex(64-bit 摘要) + * pin.salt = 16 位 hex(每次设置重新生成) + * enabled=1 但 hash/salt 缺失或格式不合法时一律按「未启用」处理(fail-closed)。 + * + * 本模块只依赖 include/meow_ui_config.h 的三个声明(get/set/remove/int),不碰 UI; + * 宿主测试链 test_pin.c 内的 meow_config_* 存根即可,无需 SDK/设备。 + */ +#ifndef MR_PIN_H +#define MR_PIN_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_PIN_MIN_LEN 4 /* 允许的最短位数 */ +#define MR_PIN_MAX_LEN 8 /* 允许的最长位数 */ + +/* 返回码(set/change/disable)。verify 用 1/0 表示对/错,不返回错误码。 */ +#define MR_PIN_OK 0 /* 成功 */ +#define MR_PIN_ERR_FORMAT (-1) /* PIN 非法:非 4-8 位纯数字(NULL/空/符号/超长) */ +#define MR_PIN_ERR_STATE (-2) /* 状态不满足:change 时尚未启用 */ +#define MR_PIN_ERR_WRONG (-3) /* change 时原 PIN 校验失败 */ +#define MR_PIN_ERR_STORE (-4) /* meow_config_* 落盘失败(无数据目录/只读/IO) */ + +/* 4-8 位纯 ASCII 数字('0'-'9')→ 1;其余(含 NULL、空串、符号、全角数字)→ 0。 */ +int mr_pin_valid(const char *pin); + +/* 当前是否启用:1=已启用且 hash/salt 完整合法;0=未启用或配置损坏(fail-closed)。 */ +int mr_pin_enabled(void); + +/* 设置/重设 PIN(生成新盐 + 迭代哈希并立即落盘)。成功即生效(下次启动进锁屏)。 + * 返回 MR_PIN_OK / MR_PIN_ERR_FORMAT / MR_PIN_ERR_STORE。 */ +int mr_pin_set(const char *pin); + +/* 校验:1=匹配;0=不匹配/未启用/输入非法(不区分,避免侧信道式提示)。 */ +int mr_pin_verify(const char *pin); + +/* 修改:先校验 old,再以新盐重设 new。失败时旧 PIN 保持有效(先校验后写盘)。 + * 返回 MR_PIN_OK / MR_PIN_ERR_STATE / MR_PIN_ERR_FORMAT / MR_PIN_ERR_WRONG / MR_PIN_ERR_STORE。 */ +int mr_pin_change(const char *old_pin, const char *new_pin); + +/* 关闭 PIN:删 hash/salt 并把 enabled 置 0(幂等;调用方已通过锁屏验证时无需再输 PIN)。 + * 返回 MR_PIN_OK / MR_PIN_ERR_STORE。 */ +int mr_pin_disable(void); + +/* 诊断/测试用:读回当前 hex 盐/哈希。返回 1=读成功,0=未设。 */ +int mr_pin_get_salt(char *out, int outsz); +int mr_pin_get_hash(char *out, int outsz); + +#ifdef __cplusplus +} +#endif +#endif /* MR_PIN_H */ diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c index d84d5d0..9945090 100644 --- a/tuku/logic/reader.c +++ b/tuku/logic/reader.c @@ -1,14 +1,61 @@ -/* logic/reader.c — 阅读方向状态(默认 RTL;M4 的阅读器直接复用)。 */ +/* logic/reader.c — 阅读方向状态(默认 RTL;M4/M6 的阅读器直接复用)。 + * + * M5 评审 P1 修复:方向设置要「下次启动生效」。最小持久化(不新增任何方向状态): + * - mr_reader_set_direction 成功切换后 best-effort 写 meow_config("reader.dir","rtl"/"ltr"); + * 写失败只打 WARN,不阻断切换(本次运行仍生效); + * - mr_reader_get_direction 首次调用惰性读一次配置恢复;无配置/值非法 → 默认 RTL; + * 之后走纯内存快路径(不再读配置)。 + * 依赖面:只 include 配置/日志两个模块头(不含 UI);宿主测试用 + * test/settings_reader_stub.c 顶替 meow_config_* 与 meow_log。 + */ #include "reader.h" +#include "include/meow_ui_config.h" +#include "include/meow_ui_log.h" + +#include + +#define MR_DIR_CFG_KEY "reader.dir" +#define MR_DIR_CFG_LTR "ltr" +#define MR_DIR_CFG_RTL "rtl" static int g_direction = MR_DIR_RTL; +static int g_restored; /* 0=尚未从配置恢复(首次 get/set 时读一次) */ + +static void reader_restore_once(void) +{ + char buf[8]; + + if (g_restored) return; + g_restored = 1; /* 无配置也只读一次:之后纯内存 */ + if (meow_config_get(MR_DIR_CFG_KEY, buf, sizeof buf) != MEOW_CFG_OK) return; + if (strcmp(buf, MR_DIR_CFG_LTR) == 0) g_direction = MR_DIR_LTR; + else if (strcmp(buf, MR_DIR_CFG_RTL) == 0) g_direction = MR_DIR_RTL; + /* 其它值:忽略,保持默认 RTL */ +} int mr_reader_get_direction(void) { + reader_restore_once(); return g_direction; } void mr_reader_set_direction(int dir) { - if (dir == MR_DIR_RTL || dir == MR_DIR_LTR) g_direction = dir; + if (dir != MR_DIR_RTL && dir != MR_DIR_LTR) return; + + reader_restore_once(); /* 先恢复:避免「没 get 过就 set」把配置里的旧方向当默认值 */ + if (g_direction == dir) return; /* 无变化不写盘 */ + g_direction = dir; + + if (meow_config_set(MR_DIR_CFG_KEY, dir == MR_DIR_LTR ? MR_DIR_CFG_LTR : MR_DIR_CFG_RTL) + != MEOW_CFG_OK) + meow_log(MEOW_LOG_WARN, "reader.dir: save failed (best-effort, kept in memory)"); } + +#ifdef MR_READER_TEST_HOOKS +void mr_reader_test_reset(void) +{ + g_direction = MR_DIR_RTL; + g_restored = 0; +} +#endif diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 917ff29..8be17e3 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,13 +1,54 @@ #!/bin/bash -# run_tests.sh — 宿主 gcc 构建并运行图库 logic 层测试(不链 SDK、不需要设备)。 +# run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 +# - test_library:书库扫描/自然排序/阅读方向(46 项) +# - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ +# 坏值/非法值/只读一次/写失败只告警) +# - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C, +# 只引用 meow_config_* 声明,测试内以内存 KV 存根顶替 → 不链 SDK)。 +# - test_settings_lock_host(可选):设置页/锁屏与 SDK view/activity/widget 接线的宿主集成 +# 验证(owner 归属 + 锁屏 SWIPE 拦截)。需要 SDK **源码树**(不只 meow_ui.a); +# 交付环境只有库+头时自动 SKIP。 +# reader.c 的方向持久化只 include 配置/日志两个模块头;宿主测试用 test/settings_reader_stub.c +# 顶替 meow_config_* 与 meow_log(所以下面各条都带 -I"$SDK" 与该存根)。 set -e cd "$(dirname "$0")/.." -gcc -Wall -Wextra -O1 -I. \ - test/test_library.c logic/library.c logic/reader.c \ +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" + +gcc -Wall -Wextra -O1 -I. -I"$SDK" \ + test/test_library.c logic/library.c logic/reader.c test/settings_reader_stub.c \ -o test/test_library ./test/test_library -gcc -Wall -Wextra -O1 -I. \ + +gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_reader_core.c logic/library.c logic/reader.c \ - logic/reader/page_source.c logic/reader/nav.c \ + logic/reader/page_source.c logic/reader/nav.c test/settings_reader_stub.c \ -o test/test_reader_core ./test/test_reader_core + +gcc -Wall -Wextra -O1 -DMR_READER_TEST_HOOKS -I. -I"$SDK" \ + test/test_settings_reader_persist.c logic/reader.c test/settings_reader_stub.c \ + -o test/test_settings_reader_persist +./test/test_settings_reader_persist + +gcc -Wall -Wextra -O1 -I. -I"$SDK" \ + test/test_pin.c logic/pin.c \ + -o test/test_pin +./test/test_pin + +if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_settings_lock_host.c ui/settings/settings.c ui/lock/lock.c \ + logic/pin.c logic/reader.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -o test/test_settings_lock_host + ./test/test_settings_lock_host +else + echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" +fi diff --git a/tuku/test/settings_reader_stub.c b/tuku/test/settings_reader_stub.c new file mode 100644 index 0000000..c4d8ea3 --- /dev/null +++ b/tuku/test/settings_reader_stub.c @@ -0,0 +1,92 @@ +/* settings_reader_stub.c — 宿主测试用 meow_config_* 与 meow_log 最小实现(内存 KV)。 + * + * 只为让 logic/reader.c 在不链 SDK 的宿主测试里可编译/可断言: + * - meow_config_get/set:8 个键的内存表,可注入写失败(模拟无卡/只读); + * - meow_log:只统计 WARN 次数(验证「写失败只告警、不阻断切换」)。 + * 与 test_pin.c 自带存根同思路,但抽成共用文件供多个 reader 相关测试链接。 + */ +#include "settings_reader_stub.h" +#include "include/meow_ui_config.h" +#include "include/meow_ui_log.h" + +#include +#include + +#define ST_MAX 8 +static struct { char key[64]; char val[64]; int used; } g_kv[ST_MAX]; +static int g_fail_writes; +static int g_warns; + +static int kv_find(const char *key) +{ + for (int i = 0; i < ST_MAX; i++) + if (g_kv[i].used && strcmp(g_kv[i].key, key) == 0) return i; + return -1; +} + +void mr_test_cfg_clear(void) +{ + memset(g_kv, 0, sizeof g_kv); + g_fail_writes = 0; +} + +void mr_test_cfg_put(const char *key, const char *val) +{ + if (!key || !key[0]) return; + int i = kv_find(key); + if (i < 0) { + for (i = 0; i < ST_MAX; i++) if (!g_kv[i].used) break; + if (i >= ST_MAX) return; + g_kv[i].used = 1; + snprintf(g_kv[i].key, sizeof g_kv[i].key, "%s", key); + } + snprintf(g_kv[i].val, sizeof g_kv[i].val, "%s", val ? val : ""); +} + +int mr_test_cfg_get(const char *key, char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + int i = kv_find(key); + if (i < 0) return 0; + snprintf(out, (size_t)outsz, "%s", g_kv[i].val); + return 1; +} + +void mr_test_cfg_fail_writes(int on) +{ + g_fail_writes = on ? 1 : 0; +} + +int mr_test_log_warns(void) +{ + return g_warns; +} + +void mr_test_log_clear(void) +{ + g_warns = 0; +} + +/* ---- 被测代码调用的 SDK 接口 ---- */ +int meow_config_set(const char *key, const char *val) +{ + if (!key || !key[0]) return MEOW_CFG_ERR_INVAL; + if (g_fail_writes) return MEOW_CFG_ERR_IO; + mr_test_cfg_put(key, val); + return MEOW_CFG_OK; +} + +int meow_config_get(const char *key, char *out, int outsz) +{ + if (!out || outsz <= 0) return MEOW_CFG_ERR_INVAL; + out[0] = '\0'; + if (!mr_test_cfg_get(key, out, outsz)) return MEOW_CFG_ERR_NOENT; + return MEOW_CFG_OK; +} + +void meow_log(meow_log_level lvl, const char *fmt, ...) +{ + (void)fmt; + if (lvl >= MEOW_LOG_WARN) g_warns++; +} diff --git a/tuku/test/settings_reader_stub.h b/tuku/test/settings_reader_stub.h new file mode 100644 index 0000000..356e643 --- /dev/null +++ b/tuku/test/settings_reader_stub.h @@ -0,0 +1,24 @@ +/* settings_reader_stub.h — 宿主测试用 meow_config_* 与 meow_log 存根(logic/reader.c 的依赖面)。 + * + * 内存 KV(不落盘)+ 测试辅助:读/写/清配置、注入写失败、统计 WARN 次数。 + * 供 test_library、test_reader_core、test_settings_reader_persist 共用—— + * 这几个测试都不链 SDK,reader.c 的持久化失败/成功路径靠这里可控。 + */ +#ifndef SETTINGS_READER_STUB_H +#define SETTINGS_READER_STUB_H + +#ifdef __cplusplus +extern "C" { +#endif + +void mr_test_cfg_clear(void); /* 清空全部键 */ +void mr_test_cfg_put(const char *key, const char *val); /* 直接写入(可注入坏值) */ +int mr_test_cfg_get(const char *key, char *out, int outsz); /* 1=键存在 */ +void mr_test_cfg_fail_writes(int on); /* 1=后续 set 全部失败 */ +int mr_test_log_warns(void); /* meow_log(WARN..) 次数 */ +void mr_test_log_clear(void); + +#ifdef __cplusplus +} +#endif +#endif /* SETTINGS_READER_STUB_H */ diff --git a/tuku/test/test_pin.c b/tuku/test/test_pin.c new file mode 100644 index 0000000..dfa555b --- /dev/null +++ b/tuku/test/test_pin.c @@ -0,0 +1,320 @@ +/* test_pin.c — 启动 PIN(logic/pin)宿主测试:纯 C,不链 SDK。 + * + * 用内存 KV 存根顶替 meow_config_get/set/int/remove(与 SDK 语义一致: + * 未设 → MEOW_CFG_ERR_NOENT;写失败可由 g_fail_write 注入)。 + * 覆盖:格式校验边界(4/8 位合法;3/9 位、空串、NULL、符号、全角数字非法)、 + * 设置/校验、错 PIN、重设换盐、change 全分支、disable、配置残缺 fail-closed、 + * 落盘失败、落盘布局(pin.enabled/hash/salt)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I"$SDK" test/test_pin.c logic/pin.c -o test/test_pin + * 或直接 test/run_tests.sh。 + */ +#include "logic/pin.h" +#include "include/meow_ui_config.h" + +#include +#include +#include + +#define KV_MAX 8 +static struct { char key[64]; char val[128]; int used; } g_kv[KV_MAX]; +static int g_fail_write; /* 1=模拟全部落盘失败 */ +static const char *g_fail_key; /* 非 NULL=只让该键写失败(模拟半途失败) */ + +static int kv_find(const char *key) +{ + for (int i = 0; i < KV_MAX; i++) + if (g_kv[i].used && strcmp(g_kv[i].key, key) == 0) return i; + return -1; +} + +static void kv_clear(void) +{ + memset(g_kv, 0, sizeof g_kv); + g_fail_write = 0; + g_fail_key = NULL; +} + +int meow_config_set(const char *key, const char *val) +{ + if (!key || !key[0]) return MEOW_CFG_ERR_INVAL; + if (g_fail_write || (g_fail_key && strcmp(g_fail_key, key) == 0)) return MEOW_CFG_ERR_IO; + int i = kv_find(key); + if (i < 0) { + for (i = 0; i < KV_MAX; i++) if (!g_kv[i].used) break; + if (i >= KV_MAX) return MEOW_CFG_ERR_IO; + g_kv[i].used = 1; + snprintf(g_kv[i].key, sizeof g_kv[i].key, "%s", key); + } + snprintf(g_kv[i].val, sizeof g_kv[i].val, "%s", val ? val : ""); + return MEOW_CFG_OK; +} + +int meow_config_get(const char *key, char *out, int outsz) +{ + if (!out || outsz <= 0) return MEOW_CFG_ERR_INVAL; + out[0] = '\0'; + int i = kv_find(key); + if (i < 0) return MEOW_CFG_ERR_NOENT; + snprintf(out, (size_t)outsz, "%s", g_kv[i].val); + return MEOW_CFG_OK; +} + +int meow_config_int(const char *key, int def) +{ + char buf[32]; + char *end = NULL; + long v; + if (meow_config_get(key, buf, sizeof buf) != MEOW_CFG_OK) return def; + v = strtol(buf, &end, 10); + if (end == buf) return def; + return (int)v; +} + +int meow_config_bool(const char *key, int def) +{ + return meow_config_int(key, def) != 0; +} + +void meow_config_remove(const char *key) +{ + int i = kv_find(key); + if (i >= 0) g_kv[i].used = 0; +} + +const char *meow_config_dir(void) { return "/tmp/meowcfg_test"; } + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int is_hex16(const char *s) +{ + if (!s) return 0; + if (strlen(s) != 16) return 0; + for (int i = 0; i < 16; i++) + if (!((s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f'))) return 0; + return 1; +} + +static void test_format(void) +{ + CHECK(mr_pin_valid(NULL) == 0, "NULL invalid"); + CHECK(mr_pin_valid("") == 0, "empty invalid"); + CHECK(mr_pin_valid("123") == 0, "3 digits invalid"); + CHECK(mr_pin_valid("1234") == 1, "4 digits valid (min)"); + CHECK(mr_pin_valid("12345") == 1, "5 digits valid"); + CHECK(mr_pin_valid("12345678") == 1, "8 digits valid (max)"); + CHECK(mr_pin_valid("123456789") == 0, "9 digits invalid"); + CHECK(mr_pin_valid("12a4") == 0, "letter invalid"); + CHECK(mr_pin_valid("12 4") == 0, "space invalid"); + CHECK(mr_pin_valid(" 1234") == 0, "leading space invalid"); + CHECK(mr_pin_valid("1234 ") == 0, "trailing space invalid"); + CHECK(mr_pin_valid("-123") == 0, "sign invalid"); + /* 全角数字(UTF-8 EF BC 9x)不是 ASCII 数字 */ + CHECK(mr_pin_valid("\xEF\xBC\x91\xEF\xBC\x92\xEF\xBC\x93\xEF\xBC\x94") == 0, + "fullwidth digits invalid"); +} + +static void test_default_state(void) +{ + kv_clear(); + CHECK(mr_pin_enabled() == 0, "default disabled"); + CHECK(mr_pin_verify("1234") == 0, "verify on disabled = 0"); + CHECK(mr_pin_set("123") == MR_PIN_ERR_FORMAT, "set short -> format error"); + CHECK(mr_pin_enabled() == 0, "failed set keeps disabled"); +} + +static void test_set_verify(void) +{ + kv_clear(); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "set 1234 ok"); + CHECK(mr_pin_enabled() == 1, "enabled after set"); + CHECK(mr_pin_verify("1234") == 1, "verify correct"); + CHECK(mr_pin_verify("4321") == 0, "verify wrong"); + CHECK(mr_pin_verify("123") == 0, "verify short"); + CHECK(mr_pin_verify("12345") == 0, "verify longer"); + CHECK(mr_pin_verify("") == 0, "verify empty"); + CHECK(mr_pin_verify(NULL) == 0, "verify NULL"); + CHECK(mr_pin_verify("12a4") == 0, "verify non-digit"); + + char salt[32] = "", hash[32] = ""; + CHECK(mr_pin_get_salt(salt, sizeof salt) == 1, "salt present"); + CHECK(mr_pin_get_hash(hash, sizeof hash) == 1, "hash present"); + CHECK(is_hex16(salt), "salt is 16 hex (got %s)", salt); + CHECK(is_hex16(hash), "hash is 16 hex (got %s)", hash); + CHECK(strcmp(hash, "1234") != 0, "hash is not the raw pin"); + + /* 落盘布局:enabled="1"(明文 PIN 不落盘:配置里只有 16 位 hex 摘要与盐) */ + char buf[64] = ""; + CHECK(meow_config_get("pin.enabled", buf, sizeof buf) == MEOW_CFG_OK && strcmp(buf, "1") == 0, + "pin.enabled=1 on disk"); +} + +static void test_reset_new_salt(void) +{ + kv_clear(); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "set first"); + char salt1[32] = "", hash1[32] = ""; + mr_pin_get_salt(salt1, sizeof salt1); + mr_pin_get_hash(hash1, sizeof hash1); + + CHECK(mr_pin_set("5678") == MR_PIN_OK, "set second (overwrite)"); + char salt2[32] = "", hash2[32] = ""; + mr_pin_get_salt(salt2, sizeof salt2); + mr_pin_get_hash(hash2, sizeof hash2); + CHECK(strcmp(salt1, salt2) != 0, "new salt on each set"); + CHECK(strcmp(hash1, hash2) != 0, "new hash on each set"); + CHECK(mr_pin_verify("5678") == 1, "new pin verifies"); + CHECK(mr_pin_verify("1234") == 0, "old pin rejected after overwrite"); + + /* 同一 PIN 重设:盐不同 → 摘要也不同(防跨设备彩虹表/同值比对)。 */ + CHECK(mr_pin_set("5678") == MR_PIN_OK, "re-set same pin"); + char salt3[32] = "", hash3[32] = ""; + mr_pin_get_salt(salt3, sizeof salt3); + mr_pin_get_hash(hash3, sizeof hash3); + CHECK(strcmp(salt2, salt3) != 0, "salt differs for same pin"); + CHECK(strcmp(hash2, hash3) != 0, "hash differs for same pin"); + CHECK(mr_pin_verify("5678") == 1, "same pin still verifies"); +} + +static void test_change(void) +{ + kv_clear(); + CHECK(mr_pin_change("1234", "5678") == MR_PIN_ERR_STATE, "change while disabled -> state"); + + CHECK(mr_pin_set("1234") == MR_PIN_OK, "setup 1234"); + CHECK(mr_pin_change("9999", "5678") == MR_PIN_ERR_WRONG, "change wrong old -> wrong"); + CHECK(mr_pin_verify("1234") == 1, "old still valid after wrong-change"); + CHECK(mr_pin_verify("5678") == 0, "new not applied after wrong-change"); + + CHECK(mr_pin_change("1234", "12") == MR_PIN_ERR_FORMAT, "change to short new -> format"); + CHECK(mr_pin_verify("1234") == 1, "old still valid after bad-new"); + + CHECK(mr_pin_change("1234", "12345678") == MR_PIN_OK, "change ok (8 digits)"); + CHECK(mr_pin_verify("12345678") == 1, "new valid after change"); + CHECK(mr_pin_verify("1234") == 0, "old invalid after change"); + CHECK(mr_pin_enabled() == 1, "still enabled after change"); + + CHECK(mr_pin_change(NULL, "0000") == MR_PIN_ERR_WRONG, "change NULL old -> wrong"); + CHECK(mr_pin_change("12345678", NULL) == MR_PIN_ERR_FORMAT, "change NULL new -> format"); +} + +static void test_disable(void) +{ + kv_clear(); + CHECK(mr_pin_disable() == MR_PIN_OK, "disable while disabled is idempotent"); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "setup for disable"); + CHECK(mr_pin_disable() == MR_PIN_OK, "disable ok"); + CHECK(mr_pin_enabled() == 0, "disabled after disable"); + CHECK(mr_pin_verify("1234") == 0, "verify fails after disable"); + + char buf[32] = ""; + CHECK(meow_config_get("pin.hash", buf, sizeof buf) == MEOW_CFG_ERR_NOENT, "hash key removed"); + CHECK(meow_config_get("pin.salt", buf, sizeof buf) == MEOW_CFG_ERR_NOENT, "salt key removed"); + CHECK(meow_config_get("pin.enabled", buf, sizeof buf) == MEOW_CFG_OK && strcmp(buf, "0") == 0, + "enabled=0 on disk"); + + /* 禁用后可重新开启(新盐)。 */ + CHECK(mr_pin_set("8765") == MR_PIN_OK, "re-enable after disable"); + CHECK(mr_pin_verify("8765") == 1, "re-enabled pin verifies"); +} + +static void test_corrupt_state(void) +{ + /* enabled=1 但缺 hash → fail-closed */ + kv_clear(); + meow_config_set("pin.enabled", "1"); + meow_config_set("pin.salt", "0011223344556677"); + CHECK(mr_pin_enabled() == 0, "enabled without hash -> fail closed"); + CHECK(mr_pin_verify("1234") == 0, "verify fail closed"); + + /* hash 长度/字符非法 */ + kv_clear(); + meow_config_set("pin.enabled", "1"); + meow_config_set("pin.salt", "0011223344556677"); + meow_config_set("pin.hash", "zz"); + CHECK(mr_pin_enabled() == 0, "garbage hash -> fail closed"); + + /* salt 非法 */ + kv_clear(); + meow_config_set("pin.enabled", "1"); + meow_config_set("pin.salt", ""); + meow_config_set("pin.hash", "0011223344556677"); + CHECK(mr_pin_enabled() == 0, "empty salt -> fail closed"); + + /* change 在残缺状态 → state 错误(不误判为已启用) */ + CHECK(mr_pin_change("1234", "5678") == MR_PIN_ERR_STATE, "change on corrupt -> state"); +} + +static void test_store_failure(void) +{ + kv_clear(); + g_fail_write = 1; + CHECK(mr_pin_set("1234") == MR_PIN_ERR_STORE, "set with failing store -> store error"); + CHECK(mr_pin_enabled() == 0, "still disabled after store failure"); + g_fail_write = 0; + + /* 写盘失败时不得把状态改成「已启用」——set 先写盐/摘要、最后写 enabled。 */ + CHECK(mr_pin_set("1234") == MR_PIN_OK, "set ok after store recovers"); + g_fail_write = 1; + CHECK(mr_pin_disable() == MR_PIN_ERR_STORE, "disable with failing store -> store error"); + g_fail_write = 0; +} + +static void test_partial_write(void) +{ + /* 摘要写失败:把盐还原成旧值,已启用的旧 PIN 仍可校验(不会新旧都验不过)。 */ + kv_clear(); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "setup 1234"); + g_fail_key = "pin.hash"; + CHECK(mr_pin_set("5678") == MR_PIN_ERR_STORE, "hash write failure -> store error"); + CHECK(mr_pin_enabled() == 1, "still enabled after partial write"); + CHECK(mr_pin_verify("1234") == 1, "old PIN still valid after partial write"); + CHECK(mr_pin_verify("5678") == 0, "new PIN not applied after partial write"); + g_fail_key = NULL; + + /* 重设时 enabled 已是 "1":该键写失败不影响一致性 → 按成功返回。 */ + g_fail_key = "pin.enabled"; + CHECK(mr_pin_set("4321") == MR_PIN_OK, "enabled-already-1 write failure is consistent"); + CHECK(mr_pin_verify("4321") == 1, "new PIN active"); + g_fail_key = NULL; + + /* 首次开启时 enabled 写失败 → 未启用(fail-closed)。 */ + kv_clear(); + g_fail_key = "pin.enabled"; + CHECK(mr_pin_set("1111") == MR_PIN_ERR_STORE, "first enable with enabled-write failure"); + CHECK(mr_pin_enabled() == 0, "not enabled after enabled-write failure"); + g_fail_key = NULL; +} + +static void test_verify_does_not_mutate(void) +{ + kv_clear(); + CHECK(mr_pin_set("2468") == MR_PIN_OK, "setup"); + char salt1[32] = "", salt2[32] = ""; + mr_pin_get_salt(salt1, sizeof salt1); + (void)mr_pin_verify("0000"); + (void)mr_pin_verify("2468"); + mr_pin_get_salt(salt2, sizeof salt2); + CHECK(strcmp(salt1, salt2) == 0, "verify does not change salt"); +} + +int main(void) +{ + test_format(); + test_default_state(); + test_set_verify(); + test_reset_new_salt(); + test_change(); + test_disable(); + test_corrupt_state(); + test_store_failure(); + test_partial_write(); + test_verify_does_not_mutate(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_settings_lock_host.c b/tuku/test/test_settings_lock_host.c new file mode 100644 index 0000000..0d3fb60 --- /dev/null +++ b/tuku/test/test_settings_lock_host.c @@ -0,0 +1,229 @@ +/* test_settings_lock_host.c — 设置页/锁屏与 SDK 骨架的宿主集成测试(可选,需 SDK 源码树)。 + * + * 目的:钉住 M3 骨架评审点名的两条坑(M5 桩注释①/②),这两条只有把真实 SDK + * view/activity/widget 链起来才能验证: + * ① 非根页控件必须在 on_enter 建(此时 activity 栈顶=本页)——否则 owner 绑到 + * ACT_LIBRARY,跨页命中泄漏; + * ② 锁屏必须在自身 view 的 on_event 消费 SWIPE(meow_view_dispatch_event 先于 app.c + * 的 on_swipe),否则左缘返回会把 ACT_LOCK page_pop 掉、PIN 被绕过。 + * 另外覆盖:设置页回到书库(topbar 返回)、控件跨进出复用不重建、阅读方向经 + * mr_reader_set_direction 切换、lock_view_should_show() 随 PIN 状态变化、 + * 主题模式切换后整帧重绘不崩,以及**真实 MEOW_IME_NUM 键盘**驱动的两条端到端路径: + * 锁屏错 PIN(留在锁屏)→ 对 PIN(page_pop 回书库)、设置页两遍输入开启 PIN。 + * + * 编译:见 test/run_tests.sh —— 它把本测试、settings/lock/pin 源码与 SDK 源码 + * (-DMEOW_UI_HOST_TEST,附 test_host_stub.c 打桩 font/global)一起编;SDK 对象清单在脚本里。 + * 只发布 meow_ui.a(无 SDK 源码)的交付环境,run_tests.sh 自动 SKIP 本测试。 + */ +#include "tuku_internal.h" +#include "logic/pin.h" +#include "meow_ui_internal.h" /* meow_widget_owner(宿主测试专用内部头) */ + +#include +#include +#include +#include +#include + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +/* app.c 的导航上报在测试里不需要网络侧实现。 */ +void mr_nav_report(void) {} + +static int g_lib_enters; +static void lib_enter(meow_view *v, void *c) { (void)v; (void)c; g_lib_enters++; } +static void lib_leave(meow_view *v, void *c) { (void)v; (void)c; } + +/* 清掉本次测试写入的三个小配置并删临时目录(meow_config 每键一文件)。 */ +static void cleanup_cfg(const char *dir) +{ + static const char *keys[3] = { "pin.enabled.cfg", "pin.hash.cfg", "pin.salt.cfg" }; + char p[512]; + for (int i = 0; i < 3; i++) { + snprintf(p, sizeof p, "%s/%s", dir, keys[i]); + unlink(p); + } + rmdir(dir); +} + +/* ============================================================ */ +/* 数字键盘注入(宿主没有真实触摸):按 MEOW_IME_NUM 键位投递 TAP,关闭键盘后由被测代码校验。 + * NUM 布局(sdk/meow_ui_ime.c build_kb):KB_TOP=420;首行 470 是符号行 "-/.:*#+=", + * 数字九键行为 518/566/614(x0=70/190/310,键宽 100),底行(ESC|0|退格+OK)为 662; + * 点 y<420 的背景=关闭键盘。 */ +/* ============================================================ */ +typedef struct { const char *digits; int passes; } pin_job; + +static void key_tap(char d) +{ + int row, col; + if (d == '0') { row = 3; col = 1; } + else { + int v = d - '1'; + if (v < 0 || v > 8) return; + row = v / 3; + col = v % 3; + } + /* 数字行 y=518/566/614(首行 470 是符号行 "-/.:*#+="),底行 662;列心 120/240/360。 */ + meow_input_inject(MEOW_EV_TAP, 70 + col * 120 + 50, 518 + row * 48 + 18); +} + +static void *pin_injector(void *arg) +{ + pin_job *job = (pin_job *)arg; + usleep(150000); /* 等 text_input 开键盘并 drain 完残留事件 */ + for (int p = 0; p < job->passes; p++) { + if (p) usleep(300000); /* 两遍输入之间等第二遍开键盘 */ + for (const char *s = job->digits; *s; s++) { + key_tap(*s); + usleep(50000); + } + usleep(50000); + meow_input_inject(MEOW_EV_TAP, 240, 100); /* y<420:关闭键盘(非空→校验) */ + } + return NULL; +} + +static void lock_try_pin(const char *digits) +{ + int sb = meow_ui_statusbar_height(); + int cy = sb + (meow_ui_height() - sb) / 2; + pin_job job = { digits, 1 }; + pthread_t th; + if (pthread_create(&th, NULL, pin_injector, &job) != 0) return; + meow_view_dispatch_event(MEOW_EV_TAP, 240, cy + 40); /* 点「输入 PIN」按钮 */ + pthread_join(th, NULL); +} + +static void settings_tap_pin_row(void){ + /* 布局:sb + 56 顶栏 + 6 + 段头34 + 方向行64 + 12 + 段头34 → PIN 列表首行中心 +32 */ + int y = meow_ui_statusbar_height() + 56 + 6 + 34 + 64 + 12 + 34 + 32; + pin_job job = { "2468", 2 }; + pthread_t th; + if (pthread_create(&th, NULL, pin_injector, &job) != 0) return; + meow_view_dispatch_event(MEOW_EV_TAP, 200, y); + pthread_join(th, NULL); +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_settings_host_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { .on_enter = lib_enter, .on_leave = lib_leave }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *settings = settings_view_create(); + meow_view *lock = lock_view_create(); + int sb = meow_ui_statusbar_height(); + + meow_view_page_push(ACT_LIBRARY); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "start at library"); + + /* ---- 设置页:on_enter 建控件 + owner 归属 ---- */ + meow_view_page_push(ACT_SETTINGS); + CHECK(meow_ui_activity_active() == ACT_SETTINGS, "settings active"); + CHECK(settings->nwidgets == 5, "settings built 5 widgets (got %d)", settings->nwidgets); + int owner_ok = 1; + for (int i = 0; i < settings->nwidgets; i++) + if (meow_widget_owner(settings->widgets[i]) != ACT_SETTINGS) owner_ok = 0; + CHECK(owner_ok, "settings widgets owned by ACT_SETTINGS (no cross-page leak)"); + + /* 布局几何:顶栏贴状态栏、三组列表不越界、页脚在屏内。 */ + int r_tb[4] = { 0 }, r_about[4] = { 0 }; + meow_widget_rect(settings->widgets[0], r_tb); + meow_widget_rect(settings->widgets[3], r_about); + CHECK(r_tb[1] == sb && r_tb[3] == 56, "topappbar sits below statusbar"); + CHECK(r_about[1] + r_about[3] < meow_ui_height() - 60, + "about list leaves room for footer (bottom=%d)", r_about[1] + r_about[3]); + printf(" geometry: topbar y=%d, about list y=%d h=%d (screen %dx%d)\n", + r_tb[1], r_about[1], r_about[3], meow_ui_width(), meow_ui_height()); + + /* 阅读方向行:段头 34 + 行中心。 */ + int dir_y = sb + 56 + 6 + 34 + 32; + int d0 = mr_reader_get_direction(); + CHECK(meow_view_dispatch_event(MEOW_EV_TAP, 120, dir_y) == 1, + "tap on direction row consumed by settings on_event"); + CHECK(mr_reader_get_direction() != d0, + "direction toggled through mr_reader_set_direction"); + + /* topbar 返回 → page_pop 回书库 */ + CHECK(meow_view_dispatch_event(MEOW_EV_TAP, 22, sb + 28) == 1, "back tap consumed"); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back popped to library"); + CHECK(g_lib_enters == 2, "library re-entered after pop (enters=%d)", g_lib_enters); + + /* 再次进入:控件复用(不在 on_enter 重复创建/泄漏) */ + meow_view_page_push(ACT_SETTINGS); + CHECK(settings->nwidgets == 5, "settings widgets reused (got %d)", settings->nwidgets); + CHECK(meow_ui_activity_active() == ACT_SETTINGS, "settings active again"); + + /* 设置页不消费 SWIPE:交给 app.c 的 page_pop */ + CHECK(meow_view_dispatch_event(MEOW_EV_SWIPE, MEOW_SWIPE_LEFT, 200) == 0, + "settings leaves SWIPE to app.c (back = pop)"); + meow_view_page_pop(); /* 手动模拟 app.c 的返回行为,回到书库 */ + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "manual pop back to library"); + + /* ---- 锁屏:PIN 状态驱动 + SWIPE 拦截(坑②) ---- */ + mr_pin_disable(); + CHECK(lock_view_should_show() == 0, "lock hidden when PIN disabled"); + CHECK(mr_pin_set("1357") == MR_PIN_OK, "enable PIN"); + CHECK(lock_view_should_show() == 1, "lock shown when PIN enabled"); + + meow_view_page_push(ACT_LOCK); + CHECK(meow_ui_activity_active() == ACT_LOCK, "lock active"); + CHECK(lock->nwidgets == 1, "lock built its button on enter (got %d)", lock->nwidgets); + CHECK(meow_widget_owner(lock->widgets[0]) == ACT_LOCK, "lock button owned by ACT_LOCK"); + + CHECK(meow_view_dispatch_event(MEOW_EV_SWIPE, MEOW_SWIPE_LEFT, 240) == 1, + "lock consumes SWIPE (left-edge back cannot bypass PIN)"); + CHECK(meow_ui_activity_active() == ACT_LOCK, "still locked after swipe"); + CHECK(meow_view_dispatch_event(MEOW_EV_TAP, 5, 400) == 1, "lock consumes stray taps"); + CHECK(meow_ui_activity_active() == ACT_LOCK, "still locked after stray tap"); + + CHECK(mr_pin_verify("1357") == 1, "stored PIN verifies"); + CHECK(mr_pin_verify("1358") == 0, "wrong PIN rejected"); + + /* ---- 锁屏真解锁路径:点按钮 → MEOW_IME_NUM 输入 → 校验 → pop(错误则留在锁屏) ---- */ + lock_try_pin("1358"); /* 错 PIN:留在锁屏 */ + CHECK(meow_ui_activity_active() == ACT_LOCK, "wrong PIN keeps lock (retry allowed)"); + lock_try_pin("1357"); /* 正确:page_pop 回书库 */ + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "correct PIN pops lock to library"); + + /* ---- 设置页两遍输入开启 PIN(MEOW_IME_NUM) ---- */ + mr_pin_disable(); + meow_view_page_push(ACT_SETTINGS); + CHECK(meow_ui_activity_active() == ACT_SETTINGS, "settings active for PIN enable"); + settings_tap_pin_row(); + CHECK(mr_pin_enabled() == 1, "two-pass entry enabled the PIN"); + CHECK(mr_pin_verify("2468") == 1, "PIN entered in settings verifies"); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back from settings to library"); + + /* 主题切换:app.c listener 整帧 invalidate;页面按 meow_theme_get() 重画不崩。 */ + meow_theme_set_mode(MEOW_THEME_LIGHT); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + meow_theme_set_mode(MEOW_THEME_DARK); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + CHECK(1, "theme switch repaint does not crash"); + + mr_pin_disable(); + meow_view_free(lock); + meow_view_free(settings); + meow_view_free(lib); + meow_ui_shutdown(); + cleanup_cfg(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "integration checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_settings_reader_persist.c b/tuku/test/test_settings_reader_persist.c new file mode 100644 index 0000000..b275adf --- /dev/null +++ b/tuku/test/test_settings_reader_persist.c @@ -0,0 +1,131 @@ +/* test_settings_reader_persist.c — 阅读方向持久化宿主测试(M5 评审 P1 修复)。 + * + * 被测:logic/reader.c 的 meow_config("reader.dir") 持久化。 + * 「新进程语义」用 reader.c 的测试钩子 mr_reader_test_reset()(-DMR_READER_TEST_HOOKS) + * 模拟:清掉静态恢复标记,配置存根保留——等价于重启后首次 get 从配置恢复。 + * + * 覆盖: + * T1 无配置默认 RTL,且 get 不写配置; + * T2 set LTR 落盘 + 内存立即生效;T3 reset 后从配置恢复 LTR; + * T4 set RTL 落盘 + reset 恢复 RTL; + * T5 坏值("sideways")忽略 → 默认 RTL,且 get 不回写坏值; + * T6 非法方向(99)忽略且不落盘; + * T7 坏值后 set 合法方向会覆盖配置; + * T8 配置只读一次(首次 get 后配置再变不影响本次内存态 = 快路径); + * T9 写失败:切换仍生效(内存)、只打一条 WARN、配置未变。 + * + * 编译(tuku/ 下,SDK 仅需公共头): + * gcc -Wall -Wextra -O1 -DMR_READER_TEST_HOOKS -I. -I"$SDK" \ + * test/test_settings_reader_persist.c logic/reader.c test/settings_reader_stub.c \ + * -o test/test_settings_reader_persist && ./test/test_settings_reader_persist + */ +#include "logic/reader.h" +#include "test/settings_reader_stub.h" + +#include +#include + +/* reader.c 在 -DMR_READER_TEST_HOOKS 下导出(正式构建无此符号,故不写进 reader.h, + * 避免生产头暴露测试入口)。 */ +extern void mr_reader_test_reset(void); + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int cfg_is(const char *want) +{ + char buf[32] = ""; + if (!mr_test_cfg_get("reader.dir", buf, (int)sizeof buf)) return 0; + return strcmp(buf, want) == 0; +} + +static int cfg_has(const char *key) +{ + char buf[32]; + return mr_test_cfg_get(key, buf, (int)sizeof buf); +} + +static void test_default_and_ltr(void) +{ + /* T1 无配置 → 默认 RTL;get 是只读的。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T1 default RTL with no config"); + CHECK(!cfg_has("reader.dir"), "T1 get does not write config"); + + /* T2 set LTR:内存立即生效 + 落盘 "ltr"。 */ + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T2 memory updated immediately"); + CHECK(cfg_is("ltr"), "T2 set LTR persists reader.dir=ltr"); + + /* T3 模拟重启 → 从配置恢复 LTR。 */ + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T3 restore LTR after restart"); + + /* T4 反向:set RTL 落盘并恢复。 */ + mr_reader_set_direction(MR_DIR_RTL); + CHECK(cfg_is("rtl"), "T4 set RTL persists reader.dir=rtl"); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T4 restore RTL after restart"); +} + +static void test_bad_values(void) +{ + /* T5 坏值忽略 → 默认 RTL,且 get 不改写坏值。 */ + mr_test_cfg_clear(); + mr_test_cfg_put("reader.dir", "sideways"); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T5 bad value ignored -> default RTL"); + CHECK(cfg_is("sideways"), "T5 get leaves bad value untouched"); + + /* T6 非法方向:忽略且不落盘。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + mr_reader_set_direction(99); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T6 invalid dir ignored (memory)"); + CHECK(!cfg_has("reader.dir"), "T6 invalid dir writes nothing"); + + /* T7 坏值后 set 合法方向 → 覆盖配置并在重启后生效。 */ + mr_test_cfg_clear(); + mr_test_cfg_put("reader.dir", "bogus"); + mr_reader_test_reset(); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(cfg_is("ltr"), "T7 valid set overwrites bad config"); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T7 restored after overwrite"); +} + +static void test_read_once_and_failure(void) +{ + /* T8 配置只读一次:首次 get 之后配置变化不影响内存(快路径)。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T8 first get with no config"); + mr_test_cfg_put("reader.dir", "ltr"); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T8 config read only once (fast path)"); + + /* T9 写失败:切换仍生效、WARN 一条、配置不变。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + mr_test_log_clear(); + mr_test_cfg_fail_writes(1); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T9 switch still applies on write failure"); + CHECK(mr_test_log_warns() == 1, "T9 one WARN on write failure (got %d)", mr_test_log_warns()); + CHECK(!cfg_has("reader.dir"), "T9 config unchanged on failure"); + mr_test_cfg_fail_writes(0); + mr_reader_set_direction(MR_DIR_RTL); + CHECK(cfg_is("rtl") && mr_test_log_warns() == 1, "T9 recovery writes rtl, no extra WARN"); +} + +int main(void) +{ + test_default_and_ltr(); + test_bad_values(); + test_read_once_and_failure(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c index a188f10..37827c8 100644 --- a/tuku/ui/lock/lock.c +++ b/tuku/ui/lock/lock.c @@ -1,49 +1,185 @@ -/* ui/lock/lock.c — 锁屏页(ACT_LOCK)M3 桩 + M5 接缝。 +/* ui/lock/lock.c — 锁屏页(ACT_LOCK):APP 启动 PIN。 * - * M5 在此实现「APP 启动 PIN」:lock_view_should_show() 读配置决定是否锁, - * 锁屏页负责 PIN 输入/校验后 page_pop 回书库。本批桩恒返回 0(不锁),页面仅可编译占位。 + * 流程(用户原话「允许用户在APP设置中设置APP启动 PIN」): + * - lock_view_should_show() = logic/pin 的 mr_pin_enabled();app.c 据此在启动时先 push + * 本页,验证通过前书库不进入/不绘制(view 注册表只画已进入的页,widget 还按 owner 过滤); + * - 本页 on_enter 建「输入 PIN」按钮(owner=ACT_LOCK),点按弹系统数字键盘(MEOW_IME_NUM), + * 校验通过 → page_pop 回书库;错误 → 就地提示重试(不做惩罚性锁定/计数); + * - 左缘返回**必须**由本页 on_event 消费 SWIPE(meow_view_dispatch_event 先于 app.c 的 + * on_swipe;否则 app.c 对非 library 页无条件 page_pop,PIN 会被绕过); + * - PIN 未启用时 app.c 直接 push 书库,本页不会被进入。 * - * 接缝注意(M5): - * ① meow_view 的 on_create 在 meow_view_create 内运行(此时 activity 栈空);本页是非根页, - * 控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 - * meow_ui_activity_push(ACT_LOCK) → 建控件 → meow_ui_activity_pop() - * (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY, - * 锁屏控件会从书库页被命中(§6.1 跨页泄漏)。 - * ② 锁屏必须经自身 view 的 on_event 消费 SWIPE:meow_view_dispatch_event 先于 App 的 - * on_swipe(sdk/meow_ui_view.h:83-88);否则 app.c 对非 library 页的左缘滑动无条件 - * page_pop,PIN 会被绕过。 + * 安全口径(诚实标注):PIN 防的是随手翻看,不是安全边界——TF 卡可拔出直读,配置目录 + * 可改。存储为加盐迭代哈希(logic/pin.c),不引入加密库;锁屏文案也如实告知用户。 + * + * 控件归属(§6.1,M3 桩注释①):on_create 时 activity 栈为空,控件一律在 on_enter 建。 */ #include "tuku_internal.h" +#include "logic/pin.h" + +#include +#include static mr_page_ctrl g_ctrl; +static meow_widget *g_btn; + +static char g_msg[64]; +static int g_msg_err; +static int g_waiting; /* 1=数字键盘打开中(上部提示改为输入说明) */ +static char g_ime_pin[MR_PIN_MAX_LEN + 12]; + +/* meow_view on_event 的事件码:公共伞头未导出(sdk/meow_ui_internal.h: + * MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3,值稳定),此处按契约镜像。 */ +#define MR_VEV_TAP 2 +#define MR_VEV_SWIPE 3 + int lock_view_should_show(void) { - return 0; /* M5:读 APP 启动 PIN 配置后返回 1 */ + return mr_pin_enabled(); } +/* ============================================================ */ +/* PIN 输入(系统数字键盘) */ +/* ============================================================ */ +static void ime_pin_change(const char *text, void *ctx) +{ + (void)ctx; + snprintf(g_ime_pin, sizeof g_ime_pin, "%s", text ? text : ""); +} + +/* 阻塞数字键盘;返回 1=收集到非空文本(关闭键盘即结束/校验)。 + * SDK 现状:键盘「确定」只置内部 submit 标记不关闭,关闭路径(取消/点背景)又把标记清 0, + * 故 meow_ui_text_input 的返回值不可作“确认”判据;文本经 on_change 实时收集,空=取消。 */ +static int lock_read_pin(char *out, int outsz) +{ + meow_ime_ctx c; + memset(&c, 0, sizeof c); + c.mode = MEOW_IME_NUM; + c.maxlen = (int)sizeof g_ime_pin; + c.on_change = ime_pin_change; + c.ctx = NULL; + + g_ime_pin[0] = '\0'; + g_msg[0] = '\0'; + g_msg_err = 0; + g_waiting = 1; /* 锁屏上部提示改为输入说明 */ + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); /* 键盘区背景快照取当前帧 */ + (void)meow_ui_text_input(&c); + g_waiting = 0; + + snprintf(out, (size_t)outsz, "%s", g_ime_pin); + return out[0] != '\0'; +} + +static void lock_attempt(void) +{ + char pin[32] = ""; + if (!lock_read_pin(pin, (int)sizeof pin)) { + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + return; /* 关闭键盘未输入:留在锁屏 */ + } + + if (!mr_pin_verify(pin)) { + snprintf(g_msg, sizeof g_msg, "PIN 错误,请重试"); + g_msg_err = 1; + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + return; + } + + g_msg[0] = '\0'; + g_msg_err = 0; + meow_view_page_pop(); /* 通过 → 回书库(ACT_LIBRARY on_enter) */ +} + +/* ============================================================ */ +/* 布局/绘制 */ +/* ============================================================ */ static void lock_draw(meow_view *v, const meow_theme *th, void *ctx) { (void)v; (void)ctx; int w = meow_ui_width(); int h = meow_ui_height(); - int cy = h / 2; + int sb = meow_ui_statusbar_height(); + int cy = sb + (h - sb) / 2; - meow_ui_text_center(w / 2, cy - 80, "LOCKED", th->font_title, th->primary); - meow_ui_text_center(w / 2, cy - 36, "Startup PIN (M5)", th->font_body, th->onSurface); - meow_ui_text_center(w / 2, cy + 8, "PIN entry will be implemented in M5", - th->font_body, th->onSurfaceVariant); + /* 自绘整页底色:验证前不露任何书库内容(本页是顶层唯一 entered 页)。 */ + meow_ui_fill_rect(0, sb, w, h - sb, th->bg); + + meow_ui_text_center(w / 2, cy - 150, "图库已锁定", th->font_title, th->onSurface); + if (g_waiting) { + meow_ui_text_center(w / 2, cy - 104, "输入 4-8 位数字后关闭键盘", th->font_body, + th->onSurface); + meow_ui_text_center(w / 2, cy - 76, "空输入 = 取消,回到锁屏", th->font_body, + th->onSurfaceVariant); + } else { + meow_ui_text_center(w / 2, cy - 104, "输入启动 PIN 继续", th->font_body, + th->onSurfaceVariant); + meow_ui_text_center(w / 2, cy - 76, "4-8 位数字 · 系统数字键盘", th->font_body, + th->onSurfaceVariant); + } + + if (g_msg[0]) { + meow_ui_text_center(w / 2, cy - 26, g_msg, th->font_body, + g_msg_err ? th->error : th->primary); + } + + /* 安全口径(如实告知):PIN 不是安全边界。 */ + meow_ui_text_center(w / 2, h - 86, "PIN 仅用于防止随手查看,", th->font_body, + th->onSurfaceVariant); + meow_ui_text_center(w / 2, h - 58, "TF 卡可被直接读取,不构成安全边界。", th->font_body, + th->onSurfaceVariant); } +static void lock_place(meow_view *v) +{ + int w = meow_ui_width(); + int h = meow_ui_height(); + int sb = meow_ui_statusbar_height(); + int cy = sb + (h - sb) / 2; + + v->x = 0; v->y = 0; v->w = w; v->h = h; + + int bw = 220, bh = 52; + if (g_btn) meow_widget_set_rect(g_btn, (w - bw) / 2, cy + 20, bw, bh); +} + +/* ============================================================ */ +/* 事件 */ +/* ============================================================ */ +static int lock_on_event(meow_view *v, int ev, int a, int b, void *ctx) +{ + (void)v; (void)ctx; + + /* 左缘返回(以及任何滑动)一律吞掉:锁屏不允许退出 App,也不允许绕过 PIN。 */ + if (ev == MR_VEV_SWIPE) return 1; + + if (ev == MR_VEV_TAP) { + int x = a, y = b; + if (meow_widget_hit(g_btn, x, y)) lock_attempt(); + return 1; /* 锁屏页消费点击,不向 App 其它页透传 */ + } + return 0; +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ static void lock_page_enter(meow_view *v, void *ctx) { ((mr_page_ctrl *)ctx)->active = 1; - v->x = 0; - v->y = 0; - v->w = meow_ui_width(); - v->h = meow_ui_height(); meow_ui_set_fullscreen(0); meow_ui_set_page_bg(meow_theme_get()->bg); + + g_msg[0] = '\0'; + g_msg_err = 0; + + if (!g_btn) { + /* page_push 已先 meow_ui_activity_push(ACT_LOCK) → 控件 owner 正确。 */ + g_btn = meow_btn_create_variant(0, 0, 220, 52, "输入 PIN", MEOW_BTN_FILLED); + if (g_btn) meow_view_add_widget(v, g_btn); + } + lock_place(v); mr_nav_report(); meow_ui_paint_full(); } @@ -54,12 +190,20 @@ static void lock_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; } +static void lock_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + g_btn = NULL; /* 控件随 meow_view_free 释放(v->widgets) */ +} + meow_view *lock_view_create(void) { static const meow_view_hooks h = { - .on_enter = lock_page_enter, - .on_leave = lock_page_leave, - .on_draw = lock_draw, + .on_enter = lock_page_enter, + .on_leave = lock_page_leave, + .on_destroy = lock_page_destroy, + .on_draw = lock_draw, + .on_event = lock_on_event, }; return meow_view_create(ACT_LOCK, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); } diff --git a/tuku/ui/settings/settings.c b/tuku/ui/settings/settings.c index ef92ca2..efbf3ed 100644 --- a/tuku/ui/settings/settings.c +++ b/tuku/ui/settings/settings.c @@ -1,45 +1,420 @@ -/* ui/settings/settings.c — 设置页(ACT_SETTINGS)M3 骨架。 +/* ui/settings/settings.c — 设置页(ACT_SETTINGS):MD3 分组表单。 * - * M5 在此实现 APP 启动 PIN 等设置项;本批只落可编译桩,入口为书库 TopAppBar ⚙。 - * 返回:左缘返回(app.c SWIPE_LEFT → page_pop)。 + * 内容(用户原话「允许用户在APP设置中设置APP启动 PIN」+「同步系统主题状态」): + * 阅读:阅读方向 RTL/LTR —— 直接读写 M3 的 mr_reader_get/set_direction(不另立状态); + * 安全:启动 PIN 开关(开启需两遍 MEOW_IME_NUM 输入确认)、修改 PIN; + * 关于:版本号、主题状态(跟随系统 + 当前明暗,随系统实时同步)。 * - * 接缝注意(M5):meow_view 的 on_create 在 meow_view_create 内运行(activity 栈空); - * 本页是非根页,控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 - * meow_ui_activity_push(ACT_SETTINGS) → 建控件 → meow_ui_activity_pop() - * (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY,设置控件会从 - * 书库页被命中(§6.1 跨页泄漏)。 + * 观感/主题:SDK 的 meow_listview 容器模式(MD3 surfaceContainer 圆角卡 + 行间空隙) + * + 段头标签;开关/箭头由 SDK renderer 用主题语义色绘制;所有颜色取 meow_theme_get(), + * 不缓存颜色——app.c 的主题 listener 整帧 invalidate,本页 on_draw/src 回调下一帧即用新色。 + * + * 控件归属(§6.1,M3 桩注释①):本页非根页,控件**必须**在 on_enter 里建(此时 + * activity 栈顶=ACT_SETTINGS),否则 owner 会绑到 ACT_LIBRARY 造成跨页命中。 + * + * 返回:左缘返回由 app.c 的 on_swipe 统一 page_pop(本页 on_event 不消费 SWIPE)。 + * PIN 输入:SDK 系统数字键盘(MEOW_IME_NUM),不自绘键盘;文本经 on_change 实时收集, + * 「非空 + 关闭键盘 = 校验、空 = 取消」(SDK 阻塞 IME 的返回值不能当确认判据,见 + * settings_read_pin 注释);键盘打开期间步骤提示画在键盘面板上方(置顶 widget)。 */ #include "tuku_internal.h" +#include "logic/pin.h" + +#include +#include + +#define ST_TOP_H 56 +#define ST_SEC_H 34 +#define ST_ROW_H 64 +#define ST_GAP 12 + +/* meow_view on_event 的事件码:公共伞头未导出(定义在 sdk/meow_ui_internal.h: + * MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3,值稳定)。此处按契约镜像,避免 App include SDK 内部头。 + * (已在交付报告里作为 SDK 缺口提出:建议公共头导出事件枚举。) */ +#define MR_VEV_TAP 2 + +/* PIN 行下标(g_lv_pin) */ +#define ST_PIN_ROW_SWITCH 0 +#define ST_PIN_ROW_CHANGE 1 static mr_page_ctrl g_ctrl; +static meow_widget *g_tb; +static meow_widget *g_lv_dir; /* 阅读:1 行 */ +static meow_widget *g_lv_pin; /* 安全:2 行 */ +static meow_widget *g_lv_about; /* 关于:2 行 */ +static meow_widget *g_overlay; /* 置顶提示条(仅拨号键盘期间显示步骤文案) */ + +static int g_yl_dir, g_yl_pin, g_yl_about; /* 段头标签 y */ +static int g_note_y; +static int g_ov_y, g_ov_h; /* 提示条几何(App 自记;widget 句柄不透明) */ + +static char g_note[96]; +static int g_note_err; +static int g_pin_on; /* 缓存的 PIN 启用态(避免每帧读 3 个小配置文件的 TF IO) */ +static char g_theme_line[64]; +static char g_prompt[64]; /* 非空=正在输入 PIN(键盘打开期间顶部提示条) */ +static char g_ime_pin[MR_PIN_MAX_LEN + 12]; + +/* ============================================================ */ +/* 文案/状态 */ +/* ============================================================ */ +static int theme_is_dark(const meow_theme *th) +{ + int r = (int)((th->surface >> 16) & 0xFF); + int g = (int)((th->surface >> 8) & 0xFF); + int b = (int)(th->surface & 0xFF); + return (r * 299 + g * 587 + b * 114) / 1000 < 128; +} + +static void theme_line_update(void) +{ + const meow_theme *th = meow_theme_get(); + meow_theme_mode m = meow_theme_get_mode(); + const char *mode = (m == MEOW_THEME_AUTO) ? "跟随系统" + : (m == MEOW_THEME_DARK) ? "固定深色" : "固定浅色"; + snprintf(g_theme_line, sizeof g_theme_line, "%s · 当前%s", mode, + theme_is_dark(th) ? "深色" : "浅色"); +} + +static void settings_note(const char *msg, int is_err) +{ + snprintf(g_note, sizeof g_note, "%s", msg ? msg : ""); + g_note_err = is_err; +} + +static void settings_repaint(void) +{ + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +/* ============================================================ */ +/* PIN 输入(系统数字键盘) */ +/* ============================================================ */ +static void ime_pin_change(const char *text, void *ctx) +{ + (void)ctx; + snprintf(g_ime_pin, sizeof g_ime_pin, "%s", text ? text : ""); +} + +/* 阻塞弹出 MEOW_IME_NUM 收集 4-8 位数字。返回 1=收到非空文本;0=空(视为取消)。 + * 说明(SDK 现状,如实记录):阻塞 IME 只以「关闭键盘」结束;数字键盘的「确定」只置内部 + * submit 标记而不关闭,随后「取消/点背景」关闭时该标记又被清 0(sdk/meow_ui_ime.c 的 + * K_ESC/背景路径),故 meow_ui_text_input 的返回值不可作为「确认」判据。本页改用 + * on_change 实时收集文本:非空 + 关闭键盘 = 校验;空 = 取消(提示条照此告知用户)。 + * 键盘面板覆盖屏底(y≥420),步骤提示画在其上方,否则打字时看不见当前步骤。 */ +static int settings_read_pin(const char *prompt, char *out, int outsz) +{ + meow_ime_ctx c; + memset(&c, 0, sizeof c); + c.mode = MEOW_IME_NUM; + c.maxlen = (int)sizeof g_ime_pin; + c.on_change = ime_pin_change; + c.ctx = NULL; + + g_ime_pin[0] = '\0'; + settings_note("", 0); + snprintf(g_prompt, sizeof g_prompt, "%s", prompt); + settings_repaint(); /* 先画提示条(键盘区背景快照从当前帧取) */ + (void)meow_ui_text_input(&c); + g_prompt[0] = '\0'; + + snprintf(out, (size_t)outsz, "%s", g_ime_pin); + return out[0] != '\0'; +} + +/* 两遍输入(开启/修改共用):1=一致且合法(写入 out);0=取消/不一致/非法(已置 note)。 */ +static int settings_pin_two_pass(const char *prompt, char *out, int outsz) +{ + char a[32] = "", b[32] = ""; + + if (!settings_read_pin(prompt, a, (int)sizeof a)) { + settings_note("已取消", 0); + return 0; + } + if (!mr_pin_valid(a)) { + settings_note("PIN 需为 4-8 位数字,未保存", 1); + return 0; + } + if (!settings_read_pin("再次输入以确认", b, (int)sizeof b)) { + settings_note("已取消", 0); + return 0; + } + if (strcmp(a, b) != 0) { + settings_note("两次输入不一致,未保存", 1); + return 0; + } + snprintf(out, (size_t)outsz, "%s", a); + return 1; +} + +static void settings_toggle_pin(void) +{ + if (!g_pin_on) { + char pin[32] = ""; + if (!settings_pin_two_pass("设置启动 PIN(4-8 位数字)", pin, (int)sizeof pin)) return; + int r = mr_pin_set(pin); + if (r == MR_PIN_OK) settings_note("启动 PIN 已开启 · 下次启动生效", 0); + else settings_note("保存失败:数据目录不可写", 1); + g_pin_on = mr_pin_enabled(); + return; + } + + int r = mr_pin_disable(); + if (r == MR_PIN_OK) settings_note("启动 PIN 已关闭", 0); + else settings_note("关闭失败:配置不可写", 1); + g_pin_on = mr_pin_enabled(); +} + +static void settings_change_pin(void) +{ + if (!g_pin_on) { + settings_note("请先开启启动 PIN", 0); + return; + } + char pin[32] = ""; + if (!settings_pin_two_pass("设置新 PIN(4-8 位数字)", pin, (int)sizeof pin)) return; + int r = mr_pin_set(pin); /* 重设即换新盐;旧 PIN 失效 */ + if (r == MR_PIN_OK) settings_note("PIN 已更新 · 下次启动生效", 0); + else settings_note("保存失败:数据目录不可写", 1); + g_pin_on = mr_pin_enabled(); +} + +/* ============================================================ */ +/* 行数据源(src2:每帧重取 → 开关/副标题随状态即时刷新) */ +/* ============================================================ */ +static int src_dir(int idx, meow_list_item *it, void *ctx) +{ + (void)ctx; + if (idx != 0) return 0; + memset(it, 0, sizeof *it); + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + it->title = "阅读方向"; + it->subtitle = rtl ? "右到左 · 日式漫画(RTL)" : "左到右 · 常规漫画(LTR)"; + it->right = MEOW_LIST_RIGHT_SWITCH; + it->on = rtl; + it->enabled = 1; + return 1; +} + +static int src_pin(int idx, meow_list_item *it, void *ctx) +{ + (void)ctx; + memset(it, 0, sizeof *it); + int on = g_pin_on; + if (idx == ST_PIN_ROW_SWITCH) { + it->title = "启动 PIN"; + it->subtitle = on ? "已启用 · 启动时先输入 PIN" + : "未设置 · 启动直接进入书库"; + it->right = MEOW_LIST_RIGHT_SWITCH; + it->on = on; + it->enabled = 1; + return 1; + } + if (idx == ST_PIN_ROW_CHANGE) { + it->title = "修改 PIN"; + it->subtitle = on ? "重新设置 4-8 位数字" : "开启后可修改"; + it->right = MEOW_LIST_RIGHT_ARROW; + it->enabled = on; /* 未启用时变淡且不响应命中 */ + return 1; + } + return 0; +} + +static int src_about(int idx, meow_list_item *it, void *ctx) +{ + (void)ctx; + memset(it, 0, sizeof *it); + if (idx == 0) { + it->title = "版本"; + it->subtitle = "0.1 · os.meow.picbrowser"; + it->enabled = 1; + return 1; + } + if (idx == 1) { + theme_line_update(); + it->title = "主题"; + it->subtitle = g_theme_line; + it->enabled = 1; + return 1; + } + return 0; +} + +/* ============================================================ */ +/* 布局 */ +/* ============================================================ */ +/* 置顶提示条(z=MEOW_Z_TOP):键盘面板从屏底覆盖,步骤文案必须画在其上方; + * 仅 g_prompt 非空时绘制,否则透明不挡顶栏。 + * 注:meow_widget 对 App 不透明(公共头只给句柄),坐标由本文件自己记录。 */ +static void settings_overlay_render(meow_widget *w, const meow_theme *th, void *ctx) +{ + (void)w; (void)ctx; + if (!g_prompt[0]) return; + int wd = meow_ui_width(); + meow_ui_fill_rect(0, g_ov_y, wd, g_ov_h, th->surfaceContainerHighest); + meow_ui_text_center(wd / 2, g_ov_y + 28, g_prompt, th->font_title, th->onSurface); + meow_ui_text_center(wd / 2, g_ov_y + 76, "输入后关闭键盘即校验 · 空输入=取消", + th->font_body, th->onSurfaceVariant); + meow_ui_fill_rect(0, g_ov_y + g_ov_h - 1, wd, 1, th->outlineVariant); +} + +static void settings_build(void) +{ + /* 控件先以占位矩形创建,统一由 settings_place() 摆位(避免两处布局数学不一致)。 */ + g_tb = meow_topappbar_create(0, 0, 1, 1, "设置"); + if (g_tb) meow_topappbar_set_back(g_tb, 1); + + g_lv_dir = meow_listview_create(0, 0, 1, 1, 1, src_dir, NULL, MEOW_LIST_SEL_NONE); + g_lv_pin = meow_listview_create(0, 0, 1, 1, 2, src_pin, NULL, MEOW_LIST_SEL_NONE); + g_lv_about = meow_listview_create(0, 0, 1, 1, 2, src_about, NULL, MEOW_LIST_SEL_NONE); + + /* 列表:MD3 容器卡 + 双行(标题/副标题)+ 右尾件(开关/箭头)。 + * 容器模式由 SDK 默认 renderer 绘制(surfaceContainer 圆角卡 + 行间空隙)。 */ + meow_widget *lvs[3] = { g_lv_dir, g_lv_pin, g_lv_about }; + for (int i = 0; i < 3; i++) { + if (!lvs[i]) continue; + meow_listview_set_item_h(lvs[i], ST_ROW_H); + meow_listview_set_item_renderer(lvs[i], 2, 0, 1); + meow_listview_set_container(lvs[i], 1); + } + + g_overlay = meow_widget_custom_create(0, 0, 1, 1, + settings_overlay_render, NULL, NULL); + if (g_overlay) meow_widget_set_z(g_overlay, MEOW_Z_TOP); +} + +/* 布局参数(窗口可能变化;每次进入重新摆位)。 */ +static void settings_place(meow_view *v) +{ + int w = meow_ui_width(); + int sb = meow_ui_statusbar_height(); + + v->x = 0; v->y = 0; v->w = w; v->h = meow_ui_height(); + + int lx = 8, lw = w - 16; + int y = sb + ST_TOP_H + 6; + + if (g_tb) meow_widget_set_rect(g_tb, 0, sb, w, ST_TOP_H); + + g_yl_dir = y; y += ST_SEC_H; + if (g_lv_dir) meow_widget_set_rect(g_lv_dir, lx, y, lw, ST_ROW_H); + y += ST_ROW_H + ST_GAP; + + g_yl_pin = y; y += ST_SEC_H; + if (g_lv_pin) meow_widget_set_rect(g_lv_pin, lx, y, lw, ST_ROW_H * 2); + y += ST_ROW_H * 2 + ST_GAP; + + g_yl_about = y; y += ST_SEC_H; + if (g_lv_about) meow_widget_set_rect(g_lv_about, lx, y, lw, ST_ROW_H * 2); + y += ST_ROW_H * 2 + 14; + + g_note_y = y; + + g_ov_y = sb; + g_ov_h = 128; + if (g_overlay) meow_widget_set_rect(g_overlay, 0, g_ov_y, w, g_ov_h); +} + +static void settings_section_label(int y, const char *text, const meow_theme *th) +{ + meow_ui_text(24, y + 6, text, th->font_body, th->primary); +} + +/* ============================================================ */ +/* 绘制 */ +/* ============================================================ */ static void settings_draw(meow_view *v, const meow_theme *th, void *ctx) { (void)v; (void)ctx; int w = meow_ui_width(); - int sb = meow_ui_statusbar_height(); - meow_ui_fill_rect(0, sb, w, 56, th->surfaceContainer); - meow_ui_text(w / 2, sb + 16, "Settings", th->font_title, th->onSurface); - meow_ui_text_center(w / 2, sb + 100, "APP options", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, sb + 140, "Startup PIN — M5", th->font_body, th->onSurface); - meow_ui_text_center(w / 2, sb + 180, "Reader direction — M4", th->font_body, - th->onSurface); - meow_ui_text_center(w / 2, sb + 240, "Theme follows the system automatically", - th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, sb + 300, "left-edge swipe = back", th->font_body, - th->onSurfaceVariant); + settings_section_label(g_yl_dir, "阅读", th); + settings_section_label(g_yl_pin, "安全", th); + settings_section_label(g_yl_about, "关于", th); + + if (g_note[0]) { + meow_ui_text(24, g_note_y, g_note, th->font_body, + g_note_err ? th->error : th->primary); + } + int hy = g_note_y + (g_note[0] ? 30 : 0); + meow_ui_text(24, hy, "改动即时保存,下次启动生效", th->font_body, th->onSurfaceVariant); + meow_ui_text(24, hy + 28, "PIN 仅防随手查看,非安全边界", th->font_body, + th->onSurfaceVariant); + meow_ui_fill_rect(0, meow_ui_height() - 1, w, 1, th->outlineVariant); } +/* ============================================================ */ +/* 事件 */ +/* ============================================================ */ +static int settings_on_event(meow_view *v, int ev, int a, int b, void *ctx) +{ + (void)v; (void)ctx; + if (ev == MR_VEV_TAP) { + int x = a, y = b; + if (g_tb) { + int hit = meow_topappbar_hit(g_tb, x, y); + if (hit == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return 1; } + if (hit >= 0) return 1; + } + if (g_lv_dir) { + int idx = meow_listview_hit(g_lv_dir, x, y); + if (idx == 0) { + mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL + ? MR_DIR_LTR : MR_DIR_RTL); + settings_note(mr_reader_get_direction() == MR_DIR_RTL + ? "阅读方向:右到左(RTL)" + : "阅读方向:左到右(LTR)", 0); + settings_repaint(); + return 1; + } + } + if (g_lv_pin) { + int idx = meow_listview_hit(g_lv_pin, x, y); + if (idx == ST_PIN_ROW_SWITCH) { + settings_toggle_pin(); + settings_repaint(); + return 1; + } + if (idx == ST_PIN_ROW_CHANGE) { + settings_change_pin(); + settings_repaint(); + return 1; + } + } + if (g_lv_about) { + int idx = meow_listview_hit(g_lv_about, x, y); + if (idx == 1) { + settings_note("主题跟随系统设置,App 内不可改", 0); + settings_repaint(); + return 1; + } + if (idx == 0) return 1; /* 版本行:仅展示 */ + } + return 0; + } + /* SWIPE 不消费:app.c 对非 library 页的左缘返回统一 page_pop。 */ + return 0; +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ static void settings_page_enter(meow_view *v, void *ctx) { ((mr_page_ctrl *)ctx)->active = 1; - v->x = 0; - v->y = 0; - v->w = meow_ui_width(); - v->h = meow_ui_height(); - meow_ui_set_fullscreen(0); + meow_ui_set_fullscreen(0); /* 保留系统状态栏(与书库一致) */ meow_ui_set_page_bg(meow_theme_get()->bg); + + if (!g_tb) { + /* 栈顶此时已是 ACT_SETTINGS(page_push 先 push 再 on_enter)→ 控件 owner 正确。 */ + settings_build(); + meow_widget *all[5] = { g_tb, g_lv_dir, g_lv_pin, g_lv_about, g_overlay }; + for (int i = 0; i < 5; i++) + if (all[i]) meow_view_add_widget(v, all[i]); + } + settings_place(v); + g_pin_on = mr_pin_enabled(); /* 进入页时读一次;页内改动由操作函数同步 */ mr_nav_report(); meow_ui_paint_full(); } @@ -50,12 +425,25 @@ static void settings_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; } +static void settings_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + /* 控件随 meow_view_free 统一释放(v->widgets);这里只清指针,避免悬垂。 */ + g_tb = NULL; + g_lv_dir = NULL; + g_lv_pin = NULL; + g_lv_about = NULL; + g_overlay = NULL; +} + meow_view *settings_view_create(void) { static const meow_view_hooks h = { - .on_enter = settings_page_enter, - .on_leave = settings_page_leave, - .on_draw = settings_draw, + .on_enter = settings_page_enter, + .on_leave = settings_page_leave, + .on_destroy = settings_page_destroy, + .on_draw = settings_draw, + .on_event = settings_on_event, }; return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); } From ff97f3cee7e5fe04368c5b9c19c77f7c9551b63e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 21:49:33 +0800 Subject: [PATCH 08/38] =?UTF-8?q?feat(tuku):=20M7=20=E6=9D=A1=E6=BC=AB?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8E=E7=BB=AD=E8=AF=BB=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=80=E9=94=AE=E5=88=87=E6=8D=A2/?= =?UTF-8?q?=E6=AF=8F=E5=86=8C=E8=AE=B0=E5=BF=86=E3=80=81=E8=A7=86=E5=8F=A3?= =?UTF-8?q?=C2=B11=20=E6=87=92=E8=A7=A3=E7=A0=81=E3=80=81=E6=BB=91?= =?UTF-8?q?=E5=9D=97=E8=B7=B3=E9=A1=B5=E3=80=81=E5=86=8C=E9=97=B4=E8=A1=94?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 条漫:meow_scroll 跟手+fling,按滚动位置懒解码视口±1 页(驻留上限 4 页); 排版用 mr_book_meta 累加适宽高度(nav.c 纯逻辑,宿主可测) - 模式:阅读 chrome 图标一键切翻页/条漫;每册模式与 last page 经 meow_config 持久化 (reader.mode/page.),重进该册恢复模式与页码 - 续读:离开/翻页/滚动停止节流落盘;书库扫描给含图册副标题追加「· 续读 Pn」 - 册间衔接:末页前进→同父目录下一含图册首页,首页后退→上一册末页;无相邻册轻提示不退出 - 跳页:chrome 内 meow_slider,目标页预解码完成后落位(2.5s 超时降级提示) - 解码:改走 M12 预算解码 meow_img_load_file_dim;超大 PNG/BMP 先 probe 预判, 超 40MiB 原始 RGBA 跳过并显示「图片无法显示」,12MiB 以上记 WARN(如实保留残余风险) - 测试:新增条漫排版/跳页状态/模式续读持久化/册间边界宿主测试,以及真实 SDK 源码树上的 阅读器集成测试(模式切换/跳页落位/续读/跨册),test/run_webtoon_tests.sh 一键运行 --- tuku/logic/library.c | 115 ++++ tuku/logic/library.h | 12 + tuku/logic/reader.c | 125 +++- tuku/logic/reader.h | 33 +- tuku/logic/reader/nav.c | 209 +++++++ tuku/logic/reader/nav.h | 59 ++ tuku/test/run_webtoon_tests.sh | 47 ++ tuku/test/test_cont_adjacent.c | 249 ++++++++ tuku/test/test_jump_page.c | 140 +++++ tuku/test/test_mode_persist.c | 165 +++++ tuku/test/test_mode_switch_host.c | 273 +++++++++ tuku/test/test_webtoon_strip.c | 220 +++++++ tuku/ui/reader/reader.c | 976 +++++++++++++++++++++++++++--- tuku/ui/reader/reader.h | 9 + 14 files changed, 2539 insertions(+), 93 deletions(-) create mode 100755 tuku/test/run_webtoon_tests.sh create mode 100644 tuku/test/test_cont_adjacent.c create mode 100644 tuku/test/test_jump_page.c create mode 100644 tuku/test/test_mode_persist.c create mode 100644 tuku/test/test_mode_switch_host.c create mode 100644 tuku/test/test_webtoon_strip.c diff --git a/tuku/logic/library.c b/tuku/logic/library.c index 38a5d40..b640401 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -7,8 +7,14 @@ * 收进结果并自然排序——因此根视图不会列出后代条目,进入子目录时再重新扫描该层。 * * 内存:条目数组倍增分配;路径/名称定长,随 mr_library_free() 释放。 + * + * M7 追加(不改扫描语义): + * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); + * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, + * 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 */ #include "library.h" +#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include @@ -215,6 +221,17 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) else snprintf(e.subtitle, sizeof e.subtitle, "%d folder%s", e.subdirs, e.subdirs == 1 ? "" : "s"); + /* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 + * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ + if (e.has_images) { + char badge[24]; + if (mr_cont_badge(e.path, badge, (int)sizeof badge)) { + int used = (int)strlen(e.subtitle); + if (used < (int)sizeof e.subtitle - 2) + snprintf(e.subtitle + used, sizeof e.subtitle - (size_t)used, + " · %s", badge); + } + } if (lib_add(lib, &e) != 0) { closedir(d); mr_library_free(lib); @@ -245,3 +262,101 @@ int mr_path_parent(const char *path, char *out, int outsz) out[n] = '\0'; return 0; } + +/* ============================================================ */ +/* 册间衔接(M7) */ +/* ============================================================ */ +int mr_library_dir_has_images(const char *dir) +{ + if (!dir || !dir[0]) return 0; + DIR *d = opendir(dir); + if (!d) return 0; + + int found = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (name[0] == '.' || !mr_path_is_image(name)) continue; + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + struct stat st; + if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; + found = 1; + break; + } + closedir(d); + return found; +} + +static int name_ptr_cmp(const void *pa, const void *pb) +{ + const char *a = *(const char *const *)pa; + const char *b = *(const char *const *)pb; + return mr_natural_cmp(a, b); +} + +int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || (delta != 1 && delta != -1)) return -1; + + char norm[MR_PATH_MAX]; + size_t dl = strlen(dir); + while (dl > 1 && dir[dl - 1] == '/') dl--; + if (dl >= sizeof norm) return -1; + memcpy(norm, dir, dl); + norm[dl] = '\0'; + + const char *slash = strrchr(norm, '/'); + if (!slash || !slash[1]) return -1; + const char *cur = slash + 1; + + char parent[MR_PATH_MAX]; + if (mr_path_parent(norm, parent, (int)sizeof parent) != 0) return -1; + + DIR *d = opendir(parent); + if (!d) return 0; + + char **names = NULL; + int n = 0, cap = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (ignored_dir_name(name)) continue; + + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, parent, name) != 0) continue; + struct stat st; + if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; + if (!mr_library_dir_has_images(full)) continue; /* 只算「一本书」,中间目录跳过 */ + + if (n >= cap) { + int nc = cap ? cap * 2 : 16; + char **nn = (char **)realloc(names, (size_t)nc * sizeof *nn); + if (!nn) break; + names = nn; + cap = nc; + } + size_t len = strlen(name); + names[n] = (char *)malloc(len + 1); + if (!names[n]) break; + memcpy(names[n], name, len + 1); + n++; + } + closedir(d); + + int rc = 0; + if (n > 1) qsort(names, (size_t)n, sizeof *names, name_ptr_cmp); + int idx = -1; + for (int i = 0; i < n; i++) + if (strcmp(names[i], cur) == 0) { idx = i; break; } + int target = idx + delta; + if (idx >= 0 && target >= 0 && target < n) { + if (join_path(out, outsz, parent, names[target]) == 0) rc = 1; + } + for (int i = 0; i < n; i++) free(names[i]); + free(names); + return rc; +} diff --git a/tuku/logic/library.h b/tuku/logic/library.h index e97d7a6..e21268c 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -1,6 +1,8 @@ /* logic/library.h — 漫画书库扫描/排序(纯 C,不依赖 meow_ui.h)。 * * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 + * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, + * 本层只读它来拼副标题,宿主测试用配置存根顶替。) * 书库语义: * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); @@ -57,6 +59,16 @@ void mr_library_free(mr_library *lib); * 成功 0;参数非法/输出缓冲不足 -1。 */ int mr_path_parent(const char *path, char *out, int outsz); +/* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。 + * 1=有;0=没有/不可读。 */ +int mr_library_dir_has_images(const char *dir); + +/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 + * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; + * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 + * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 */ +int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); + #ifdef __cplusplus } #endif diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c index 9945090..44d633c 100644 --- a/tuku/logic/reader.c +++ b/tuku/logic/reader.c @@ -1,10 +1,12 @@ -/* logic/reader.c — 阅读方向状态(默认 RTL;M4/M6 的阅读器直接复用)。 +/* logic/reader.c — 阅读方向状态(默认 RTL;M4/M6 的阅读器直接复用)+ 每册续读状态(M7)。 * * M5 评审 P1 修复:方向设置要「下次启动生效」。最小持久化(不新增任何方向状态): * - mr_reader_set_direction 成功切换后 best-effort 写 meow_config("reader.dir","rtl"/"ltr"); * 写失败只打 WARN,不阻断切换(本次运行仍生效); * - mr_reader_get_direction 首次调用惰性读一次配置恢复;无配置/值非法 → 默认 RTL; * 之后走纯内存快路径(不再读配置)。 + * M7:阅读模式(翻页/条漫)与每册 last page 同为 meow_config 小键(reader.mode/page.<册标识>, + * 册标识=规范化目录路径的 FNV-1a64);书库行角标经 mr_cont_badge 读取同一份状态。 * 依赖面:只 include 配置/日志两个模块头(不含 UI);宿主测试用 * test/settings_reader_stub.c 顶替 meow_config_* 与 meow_log。 */ @@ -12,6 +14,8 @@ #include "include/meow_ui_config.h" #include "include/meow_ui_log.h" +#include +#include #include #define MR_DIR_CFG_KEY "reader.dir" @@ -59,3 +63,122 @@ void mr_reader_test_reset(void) g_restored = 0; } #endif + +/* ============================================================ */ +/* 阅读模式 + 每册续读状态(M7) */ +/* ============================================================ */ +int mr_mode_valid(int mode) +{ + return mode == MR_MODE_PAGED || mode == MR_MODE_WEBTOON; +} + +const char *mr_mode_name(int mode) +{ + return mode == MR_MODE_WEBTOON ? "webtoon" : "paged"; +} + +int mr_mode_parse(const char *s) +{ + if (!s) return -1; + if (strcmp(s, "paged") == 0) return MR_MODE_PAGED; + if (strcmp(s, "webtoon") == 0) return MR_MODE_WEBTOON; + return -1; +} + +/* 目录规范化:去掉末尾 '/'(根 "/" 保留),保证同一册跨调用/跨启动得到同一键。 */ +static void cont_norm_dir(const char *dir, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + out[0] = '\0'; + if (!dir || !dir[0]) return; + int n = (int)strlen(dir); + while (n > 1 && dir[n - 1] == '/') n--; + if (n >= outsz) n = outsz - 1; + memcpy(out, dir, (size_t)n); + out[n] = '\0'; +} + +static unsigned long long cont_fnv1a64(const char *s) +{ + unsigned long long h = 1469598103934665603ULL; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h ^= (unsigned long long)*p; + h *= 1099511628211ULL; + } + return h; +} + +int mr_cont_key(const char *dir, const char *field, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || !field || !field[0]) return -1; + char norm[MR_CONT_PATH_MAX]; + cont_norm_dir(dir, norm, (int)sizeof norm); + if (!norm[0]) return -1; + int n = snprintf(out, (size_t)outsz, "reader.%s.%016llx", field, cont_fnv1a64(norm)); + if (n <= 0 || n >= outsz) { out[0] = '\0'; return -1; } + return 0; +} + +int mr_cont_get_page(const char *dir, int def) +{ + char key[64]; + char buf[24]; + if (mr_cont_key(dir, "page", key, (int)sizeof key) != 0) return def; + if (meow_config_get(key, buf, (int)sizeof buf) != MEOW_CFG_OK) return def; + char *end = NULL; + long v = strtol(buf, &end, 10); + if (end == buf || (end && *end != '\0')) return def; + if (v < 0 || v > MR_CONT_PAGE_MAX) return def; + return (int)v; +} + +void mr_cont_set_page(const char *dir, int page) +{ + char key[64]; + char val[24]; + if (page < 0) page = 0; + if (page > MR_CONT_PAGE_MAX) page = MR_CONT_PAGE_MAX; + if (mr_cont_key(dir, "page", key, (int)sizeof key) != 0) return; + snprintf(val, sizeof val, "%d", page); + if (meow_config_set(key, val) != MEOW_CFG_OK) + meow_log(MEOW_LOG_WARN, "reader: save page failed (best-effort)"); +} + +int mr_cont_get_mode(const char *dir, int def) +{ + char key[64]; + char buf[16]; + if (!mr_mode_valid(def)) def = MR_MODE_PAGED; + if (mr_cont_key(dir, "mode", key, (int)sizeof key) != 0) return def; + if (meow_config_get(key, buf, (int)sizeof buf) != MEOW_CFG_OK) return def; + int m = mr_mode_parse(buf); + return m >= 0 ? m : def; +} + +void mr_cont_set_mode(const char *dir, int mode) +{ + char key[64]; + if (!mr_mode_valid(mode)) return; + if (mr_cont_key(dir, "mode", key, (int)sizeof key) != 0) return; + if (meow_config_set(key, mr_mode_name(mode)) != MEOW_CFG_OK) + meow_log(MEOW_LOG_WARN, "reader: save mode failed (best-effort)"); +} + +void mr_cont_save(const char *dir, int page, int mode) +{ + if (!dir || !dir[0]) return; + mr_cont_set_page(dir, page); + mr_cont_set_mode(dir, mode); +} + +int mr_cont_badge(const char *dir, char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + int page = mr_cont_get_page(dir, -1); + if (page <= 0) return 0; + snprintf(out, (size_t)outsz, "续读 P%d", page + 1); + return 1; +} diff --git a/tuku/logic/reader.h b/tuku/logic/reader.h index 5571cdd..fd62fcc 100644 --- a/tuku/logic/reader.h +++ b/tuku/logic/reader.h @@ -1,6 +1,7 @@ -/* logic/reader.h — 阅读器纯逻辑接缝(M4 填充实现,M3 先落状态 API)。 +/* logic/reader.h — 阅读器纯逻辑接缝(M4 填充实现,M3 先落状态 API;M7 追加模式与每册续读状态)。 * * 阅读方向默认 RTL(日式漫画从右往左翻);设置页/阅读器可切 LTR。 + * 阅读模式(翻页/条漫)与每册 last page 走 meow_config 持久化,键含册标识。 * 本模块保持纯 C(不 include meow_ui.h),宿主可直接测。 */ #ifndef MR_READER_H @@ -17,6 +18,36 @@ int mr_reader_get_direction(void); /* 只接受 MR_DIR_RTL / MR_DIR_LTR;其它值忽略(保持原方向)。 */ void mr_reader_set_direction(int dir); +/* ============================================================ */ +/* 阅读模式(M7):左右翻页 / 条漫竖向连续滚动 */ +/* ============================================================ */ +#define MR_MODE_PAGED 0 /* 左右翻页(默认) */ +#define MR_MODE_WEBTOON 1 /* 条漫:垂直连续滚动 */ + +int mr_mode_valid(int mode); +const char *mr_mode_name(int mode); /* "paged" / "webtoon"(配置值;非法按 paged) */ +int mr_mode_parse(const char *s); /* -1=未知 */ + +/* ============================================================ */ +/* 每册续读状态(last page + mode,meow_config 持久化) */ +/* ------------------------------------------------------------ + * 键 = reader..:同一册(同一目录)跨启动恢复, + * 不同册互不影响;目录改名/移动视为新册。写失败只 WARN(best-effort,不阻断阅读)。 + * 依赖:只 include 配置/日志两个模块头,宿主测试用 settings_reader_stub 顶替。 + * ============================================================ */ +#define MR_CONT_PATH_MAX 512 /* 册路径规范化的本地缓冲(同 MR_PATH_MAX 口径) */ +#define MR_CONT_PAGE_MAX 1000000 /* 配置里 last page 的合理上限(防坏值) */ + +int mr_cont_key(const char *dir, const char *field, char *out, int outsz); +int mr_cont_get_mode(const char *dir, int def); +void mr_cont_set_mode(const char *dir, int mode); +int mr_cont_get_page(const char *dir, int def); +void mr_cont_set_page(const char *dir, int page); +/* 一次写 page+mode(尽最大努力,两键分别尝试)。 */ +void mr_cont_save(const char *dir, int page, int mode); +/* 书库行续读角标:1=写入 "续读 P5"(page>0 才有记录);0=无。 */ +int mr_cont_badge(const char *dir, char *out, int outsz); + #ifdef __cplusplus } #endif diff --git a/tuku/logic/reader/nav.c b/tuku/logic/reader/nav.c index fb7d29d..4743e6f 100644 --- a/tuku/logic/reader/nav.c +++ b/tuku/logic/reader/nav.c @@ -1,5 +1,8 @@ /* logic/reader/nav.c — 阅读导航纯逻辑实现(见 nav.h 的方向语义说明)。 */ #include "logic/reader/nav.h" +#include "logic/reader/page_source.h" /* MR_BOOK_MAX_PAGES(条漫排版页数上限) */ + +#include int mr_nav_tap_zone(int x, int view_w) { @@ -118,3 +121,209 @@ int mr_nav_fit_next(int fit) if (fit < 0 || fit >= MR_FIT_COUNT) return MR_FIT_WIDTH; return (fit + 1) % MR_FIT_COUNT; } + +/* ============================================================ */ +/* 条漫排版 / 滚动窗口 */ +/* ============================================================ */ +static int strip_page_height(int src_w, int src_h, int vw) +{ + long long h; + if (vw <= 0) vw = 1; + if (src_w > 0 && src_h > 0) + h = (long long)src_h * vw / src_w; /* 适宽等比 */ + else + h = (long long)vw * 3 / 2; /* 未知尺寸:2:3 竖图占位 */ + if (h < 1) h = 1; + if (h > MR_STRIP_MAX_PAGE_H) h = MR_STRIP_MAX_PAGE_H; + return (int)h; +} + +void mr_strip_free(mr_strip *s) +{ + if (!s) return; + free(s->top); + free(s->height); + s->top = NULL; + s->height = NULL; + s->count = s->total = 0; + s->vw = s->vh = 0; +} + +int mr_strip_build(mr_strip *s, int count, int vw, int vh, + mr_strip_meta_fn meta, void *ctx) +{ + if (!s) return -1; + mr_strip_free(s); + if (count <= 0 || count > MR_BOOK_MAX_PAGES || vw <= 0 || vh <= 0) return -1; + + s->top = (int *)calloc((size_t)count + 1, sizeof *s->top); + s->height = (int *)calloc((size_t)count, sizeof *s->height); + if (!s->top || !s->height) { mr_strip_free(s); return -1; } + + s->count = count; + s->vw = vw; + s->vh = vh; + long long y = 0; + for (int i = 0; i < count; i++) { + int sw = 0, sh = 0; + if (meta && !meta(ctx, i, &sw, &sh)) { sw = sh = 0; } /* 未知/失败 → 占位 */ + int h = strip_page_height(sw, sh, vw); + s->height[i] = h; + s->top[i] = (int)y; + y += h; + if (y > 1000000000LL) { /* 累计偏移上限(防溢出;正常书远达不到) */ + y = 1000000000LL; + for (int k = i + 1; k < count; k++) { + s->height[k] = 1; + s->top[k] = (int)y; + y += 1; + if (y > 1000000000LL) y = 1000000000LL; + } + s->top[count] = (int)y; + s->total = (int)y; + return 0; + } + } + s->top[count] = (int)y; + s->total = (int)y; + return 0; +} + +int mr_strip_page_h(const mr_strip *s, int index) +{ + if (!s || !s->height || index < 0 || index >= s->count) return 0; + return s->height[index]; +} + +int mr_strip_top(const mr_strip *s, int index) +{ + if (!s || !s->top || index < 0 || index > s->count) return 0; + return s->top[index]; +} + +int mr_strip_total(const mr_strip *s) +{ + return s ? s->total : 0; +} + +int mr_strip_max_offset(const mr_strip *s) +{ + if (!s || s->count <= 0) return 0; + int m = s->total - s->vh; + return m > 0 ? m : 0; +} + +int mr_strip_page_at(const mr_strip *s, int off) +{ + if (!s || s->count <= 0 || !s->top) return -1; + if (off <= 0) return 0; + if (off >= s->total) return s->count - 1; + int lo = 0, hi = s->count - 1; + while (lo < hi) { /* 最大的 top[i] <= off */ + int mid = lo + (hi - lo + 1) / 2; + if (s->top[mid] <= off) lo = mid; + else hi = mid - 1; + } + return lo; +} + +int mr_strip_offset_for_page(const mr_strip *s, int page) +{ + if (!s || s->count <= 0) return -1; + if (page < 0) page = 0; + if (page >= s->count) page = s->count - 1; + int off = s->top[page]; + int m = mr_strip_max_offset(s); + if (off > m) off = m; + if (off < 0) off = 0; + return off; +} + +int mr_strip_window(const mr_strip *s, int off, int max_pages, + int *first, int *last) +{ + if (first) *first = 0; + if (last) *last = -1; + if (!s || s->count <= 0) return 0; + if (max_pages <= 0) max_pages = 1; + + int m = mr_strip_max_offset(s); + if (off < 0) off = 0; + if (off > m) off = m; + + int f = mr_strip_page_at(s, off); + int l = mr_strip_page_at(s, off + s->vh - 1); + if (l < f) l = f; + + int lo = f - 1; /* 视口 ±1 页 */ + int hi = l + 1; + if (lo < 0) lo = 0; + if (hi > s->count - 1) hi = s->count - 1; + if (hi - lo + 1 > max_pages) { /* 驻留上限:从视口中心向外截 */ + int center = (f + l) / 2; + lo = center - (max_pages - 1) / 2; + hi = lo + max_pages - 1; + if (lo < 0) { lo = 0; hi = max_pages - 1; } + if (hi > s->count - 1) { hi = s->count - 1; lo = hi - max_pages + 1; } + if (lo < 0) lo = 0; + } + if (first) *first = lo; + if (last) *last = hi; + return hi - lo + 1; +} + +int mr_strip_at_end(const mr_strip *s, int off) +{ + if (!s || s->count <= 0) return 0; + return off >= mr_strip_max_offset(s); +} + +int mr_strip_at_start(const mr_strip *s, int off) +{ + if (!s || s->count <= 0) return 0; + return off <= 0; +} + +/* ============================================================ */ +/* 跳页状态 */ +/* ============================================================ */ +void mr_jump_cancel(mr_jump *j) +{ + if (!j) return; + j->pending = 0; + j->target = -1; + j->t0 = 0; +} + +void mr_jump_begin(mr_jump *j, int page, int count, unsigned long long now) +{ + if (!j) return; + if (count <= 0 || page < 0 || page >= count) { mr_jump_cancel(j); return; } + j->pending = 1; + j->target = page; + j->t0 = now; +} + +int mr_jump_active(const mr_jump *j) +{ + return j && j->pending ? 1 : 0; +} + +int mr_jump_target(const mr_jump *j) +{ + return (j && j->pending) ? j->target : -1; +} + +int mr_jump_poll(mr_jump *j, int have_img, unsigned long long now, + unsigned long long timeout_ms, int *timed_out) +{ + if (timed_out) *timed_out = 0; + if (!j || !j->pending) return 0; + if (have_img) { mr_jump_cancel(j); return 1; } + if (timeout_ms > 0 && now - j->t0 >= timeout_ms) { + mr_jump_cancel(j); + if (timed_out) *timed_out = 1; + return 1; + } + return 0; +} diff --git a/tuku/logic/reader/nav.h b/tuku/logic/reader/nav.h index 4d328f3..a74b193 100644 --- a/tuku/logic/reader/nav.h +++ b/tuku/logic/reader/nav.h @@ -64,6 +64,65 @@ int mr_nav_slide_from(int rtl, int delta, int view_w); /* fit 档循环:适宽 → 适高 → 原始 → 适宽。 */ int mr_nav_fit_next(int fit); +/* ============================================================ */ +/* 条漫(M7):竖向连续滚动的排版 / 滚动窗口 / 跳页状态 */ +/* ------------------------------------------------------------ + * 排版用页源契约的 meta()(原始像素尺寸,不解码):每页按屏宽等比换算显示高度, + * 累加成「整条」的 y 偏移表。未知尺寸的页按 2:3 竖图占位(排版稳定,解码后不改布局)。 + * 全部纯整数运算、无 SDK 依赖;reader.c 与宿主测试共用(logic/reader/nav.c)。 + * ============================================================ */ +#define MR_STRIP_MAX_PAGE_H 200000 /* 单页显示高度上限(防坏头/极端长图撑爆累计偏移) */ + +typedef struct mr_strip { + int count; + int vw, vh; + int *top; /* count+1 个:top[i]=第 i 页顶端偏移;top[count]=整条总高 */ + int *height; /* count 个:每页显示高度(>=1) */ + int total; +} mr_strip; + +/* 页尺寸回调(通常是 mr_book_meta):1=已知原始尺寸,0=未知。 */ +typedef int (*mr_strip_meta_fn)(void *ctx, int index, int *w, int *h); + +/* 建排版表。0=成功;参数非法/内存不足 -1(已清空、可安全 free)。 */ +int mr_strip_build(mr_strip *s, int count, int vw, int vh, + mr_strip_meta_fn meta, void *ctx); +/* 释放偏移表并清零(可重复调用)。 */ +void mr_strip_free(mr_strip *s); + +int mr_strip_page_h(const mr_strip *s, int index); /* 越界 0 */ +int mr_strip_top(const mr_strip *s, int index); /* index==count 返回总高;越界 0 */ +int mr_strip_total(const mr_strip *s); +int mr_strip_max_offset(const mr_strip *s); /* max(0, total-vh) */ +/* 视口顶端(off)落在哪一页;空册 -1。 */ +int mr_strip_page_at(const mr_strip *s, int off); +/* 落位到某页顶端所需的滚动量(夹到 [0,max_offset]);空册 -1。 */ +int mr_strip_offset_for_page(const mr_strip *s, int page); +/* 视口可见页范围 [first,last] + ±1 解码窗,条数不超过 max_pages。 + * 返回窗口页数;空册 0。first/last 可 NULL。 */ +int mr_strip_window(const mr_strip *s, int off, int max_pages, + int *first, int *last); +int mr_strip_at_end(const mr_strip *s, int off); /* 1=已到条底 */ +int mr_strip_at_start(const mr_strip *s, int off); /* 1=已在条顶 */ + +/* ---- 跳页(chrome 滑块):目标页预解码完成后再落位,避免白屏/闪烁 ---- */ +typedef struct mr_jump { + int pending; /* 1=正在等目标页 */ + int target; /* 目标页;pending==0 时无意义 */ + unsigned long long t0; /* 开始等待的时刻(ms) */ +} mr_jump; + +/* 开始跳页:page 夹到 [0,count);count<=0 或 page<0 时取消。 */ +void mr_jump_begin(mr_jump *j, int page, int count, unsigned long long now); +int mr_jump_active(const mr_jump *j); +int mr_jump_target(const mr_jump *j); /* 无 pending 返回 -1 */ +void mr_jump_cancel(mr_jump *j); +/* 轮询:have_img=目标页是否已解码。1=可以落位(已清除 pending); + * timed_out 非 NULL 时写入「是否因超时落位」(调用方据此提示降级)。 + * 未就绪且未超时返回 0(继续等)。 */ +int mr_jump_poll(mr_jump *j, int have_img, unsigned long long now, + unsigned long long timeout_ms, int *timed_out); + #ifdef __cplusplus } #endif diff --git a/tuku/test/run_webtoon_tests.sh b/tuku/test/run_webtoon_tests.sh new file mode 100755 index 0000000..4fd3e79 --- /dev/null +++ b/tuku/test/run_webtoon_tests.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# run_webtoon_tests.sh — M7 条漫/模式/续读/跳页宿主测试(纯 C,不链 SDK、不需要设备)。 +# - test_webtoon_strip:条漫排版(适宽高度/偏移表/可见窗口/未知尺寸兜底/跳页互逆) +# - test_jump_page:跳页状态机(预解码落位/超时降级/非法目标)与落位偏移映射 +# - test_mode_persist:阅读模式与每册 last page 的 meow_config 持久化/坏值/写失败 +# - test_cont_adjacent:册间自然序边界(首/末册/嵌套/符号链接)+ 书库续读角标 +# 编译口径同 test/run_tests.sh(-I. + SDK 头,不链 meow_ui.a;配置/日志用存根)。 +set -e +cd "$(dirname "$0")/.." +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" +CFLAGS="-Wall -Wextra -O1 -I. -I$SDK" +OUT="build/host" +mkdir -p "$OUT" + +gcc $CFLAGS test/test_webtoon_strip.c logic/reader/nav.c -o "$OUT/test_webtoon_strip" +"$OUT/test_webtoon_strip" + +gcc $CFLAGS test/test_jump_page.c logic/reader/nav.c -o "$OUT/test_jump_page" +"$OUT/test_jump_page" + +gcc $CFLAGS test/test_mode_persist.c logic/reader.c test/settings_reader_stub.c \ + -o "$OUT/test_mode_persist" +"$OUT/test_mode_persist" + +gcc $CFLAGS test/test_cont_adjacent.c logic/library.c logic/reader.c \ + test/settings_reader_stub.c -o "$OUT/test_cont_adjacent" +"$OUT/test_cont_adjacent" + +# 可选:真实 SDK 源码树上的阅读器集成测试(模式切换/续读/跳页/册间衔接)。 +# 只发布 meow_ui.a 的交付环境自动 SKIP(与 test/run_tests.sh 的 settings_lock_host 同口径)。 +if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then + gcc $CFLAGS -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I"$SDK/include" \ + test/test_mode_switch_host.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -o "$OUT/test_mode_switch_host" + "$OUT/test_mode_switch_host" +else + echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" +fi diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c new file mode 100644 index 0000000..298f604 --- /dev/null +++ b/tuku/test/test_cont_adjacent.c @@ -0,0 +1,249 @@ +/* test_cont_adjacent.c — 册间顺序边界 + 书库续读角标宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * A1 「直接含图」判定:含图目录=书;只有子目录含图/空目录/仅非图文件=不是书; + * A2 兄弟册排序:同父目录、书库自然序(vol2 < vol10);首/末册无相邻返回 0; + * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; + * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; + * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; + * A6 书库扫描接线:有续读记录的册,行副标题出现「· 续读 Pn」角标。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ + * test/settings_reader_stub.c -o test/test_cont_adjacent + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/library.h" +#include "logic/reader.h" +#include "test/settings_reader_stub.h" + +#include +#include +#include +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite("x", 1, 1, f) == 1; + fclose(f); + return ok ? 0 : -1; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +static void test_has_images(const char *root) +{ + char p[MR_PATH_MAX]; + CHECK(mr_library_dir_has_images(root) == 1, + "root has a loose image file (scan/adjacency only treats subdirs as books)"); + + joinp(p, sizeof p, root, "vol1"); + CHECK(mr_library_dir_has_images(p) == 1, "vol1 is a book"); + joinp(p, sizeof p, root, "dirs_only"); + CHECK(mr_library_dir_has_images(p) == 0, "dirs_only is not a book"); + joinp(p, sizeof p, root, "empty"); + CHECK(mr_library_dir_has_images(p) == 0, "empty is not a book"); + joinp(p, sizeof p, root, "txonly"); + CHECK(mr_library_dir_has_images(p) == 0, "text-only dir is not a book"); + CHECK(mr_library_dir_has_images("/no/such/dir/xyz") == 0, "missing dir"); + CHECK(mr_library_dir_has_images(NULL) == 0 && mr_library_dir_has_images("") == 0, + "NULL/empty safe"); +} + +static void expect_neighbor(const char *dir, int delta, const char *want, const char *tag) +{ + char out[MR_PATH_MAX]; + int r = mr_book_adjacent(dir, delta, out, (int)sizeof out); + if (!want) { + CHECK(r == 0 && out[0] == '\0', "%s: no neighbor (r=%d out=%s)", tag, r, out); + } else { + CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want); + } +} + +static void test_adjacent(const char *root) +{ + char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX]; + char series_a[MR_PATH_MAX], series_b[MR_PATH_MAX]; + char dirs_only[MR_PATH_MAX], out[MR_PATH_MAX]; + + joinp(vol1, sizeof vol1, root, "vol1"); + joinp(vol2, sizeof vol2, root, "vol2"); + joinp(vol10, sizeof vol10, root, "vol10"); + joinp(series_a, sizeof series_a, root, "series/a"); + joinp(series_b, sizeof series_b, root, "series/b"); + joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); + + /* 自然序:vol1 < vol2 < vol10(vol0 是符号链接,不参与) */ + expect_neighbor(vol1, 1, vol2, "vol1 next"); + expect_neighbor(vol2, 1, vol10, "vol2 next"); + expect_neighbor(vol10, 1, NULL, "vol10 next (end)"); + expect_neighbor(vol1, -1, NULL, "vol1 prev (start)"); + expect_neighbor(vol2, -1, vol1, "vol2 prev"); + expect_neighbor(vol10, -1, vol2, "vol10 prev"); + + /* 末尾斜杠等价 */ + char slash[MR_PATH_MAX]; + snprintf(slash, sizeof slash, "%s/", vol2); + expect_neighbor(slash, 1, vol10, "trailing slash next"); + expect_neighbor(slash, -1, vol1, "trailing slash prev"); + + /* 嵌套父目录:series/a <-> series/b */ + expect_neighbor(series_a, 1, series_b, "series a next"); + expect_neighbor(series_b, -1, series_a, "series b prev"); + expect_neighbor(series_b, 1, NULL, "series b next (end)"); + + /* 不是书的目录:不在兄弟表中 */ + expect_neighbor(dirs_only, 1, NULL, "dirs_only not a book"); + expect_neighbor(dirs_only, -1, NULL, "dirs_only not a book prev"); + + /* 路径参数:delta/缓冲/不存在 */ + CHECK(mr_book_adjacent(vol1, 0, out, (int)sizeof out) == -1, "delta 0 invalid"); + CHECK(mr_book_adjacent(vol1, 2, out, (int)sizeof out) == -1, "delta 2 invalid"); + CHECK(mr_book_adjacent(vol1, -2, out, (int)sizeof out) == -1, "delta -2 invalid"); + CHECK(mr_book_adjacent(NULL, 1, out, (int)sizeof out) == -1, "NULL dir"); + CHECK(mr_book_adjacent(vol1, 1, NULL, 32) == -1, "NULL out"); + CHECK(mr_book_adjacent(vol1, 1, out, 4) == 0, "small out -> no fit (0)"); + CHECK(mr_book_adjacent("/no/such/book", 1, out, (int)sizeof out) == 0, + "missing parent -> 0"); + CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); +} + +static void test_library_badge(const char *root) +{ + mr_test_cfg_clear(); + char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX]; + joinp(vol1, sizeof vol1, root, "vol1"); + joinp(vol2, sizeof vol2, root, "vol2"); + + /* vol1 有续读记录(第 5 页),vol2 没有 */ + mr_cont_save(vol1, 4, MR_MODE_WEBTOON); + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n > 0, "library scan n=%d", n); + + int seen1 = 0, seen2 = 0; + for (int i = 0; i < lib.count; i++) { + mr_library_entry *e = &lib.items[i]; + if (strcmp(e->name, "vol1") == 0) { + seen1 = 1; + CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); + CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); + } + if (strcmp(e->name, "vol2") == 0) { + seen2 = 1; + CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); + CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); + } + } + CHECK(seen1 == 1, "vol1 listed"); + CHECK(seen2 == 1, "vol2 listed"); + mr_library_free(&lib); + + /* 记录清除后角标消失 */ + mr_cont_save(vol1, 0, MR_MODE_WEBTOON); + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "rescan"); + for (int i = 0; i < lib.count; i++) + if (strcmp(lib.items[i].name, "vol1") == 0) + CHECK(strstr(lib.items[i].subtitle, "续读") == NULL, "cleared badge gone"); + mr_library_free(&lib); +} + +int main(void) +{ + char tmpl[] = "./mr_cont_adj_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + const char *books[] = { "vol1", "vol2", "vol10", "series/a", "series/b", "dirs_only/inside" }; + for (unsigned i = 0; i < sizeof books / sizeof books[0]; i++) { + joinp(p, sizeof p, root, books[i]); + CHECK(mkdir_p(p) == 0, "mkdir %s", p); + char img[MR_PATH_MAX]; + joinp(img, sizeof img, p, "001.png"); + CHECK(touch_file(img) == 0, "image %s", img); + } + joinp(p, sizeof p, root, "empty"); + mkdir_p(p); + joinp(p, sizeof p, root, "txonly"); + mkdir_p(p); + joinp(p, sizeof p, root, "txonly/readme.txt"); + touch_file(p); + joinp(p, sizeof p, root, ".hidden"); + mkdir_p(p); + joinp(p, sizeof p, root, ".hidden/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "MeowOS"); + mkdir_p(p); + joinp(p, sizeof p, root, "MeowOS/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "loose.png"); + touch_file(p); + + char vol1[MR_PATH_MAX], vol0[MR_PATH_MAX]; + joinp(vol1, sizeof vol1, root, "vol1"); + joinp(vol0, sizeof vol0, root, "vol0"); + if (symlink(vol1, vol0) == 0) { /* 有权限才测;无权限时 symlink 失败也不影响上面的断言 */ } + + test_has_images(root); + test_adjacent(root); + test_library_badge(root); + + rm_rf(root); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_jump_page.c b/tuku/test/test_jump_page.c new file mode 100644 index 0000000..7441a21 --- /dev/null +++ b/tuku/test/test_jump_page.c @@ -0,0 +1,140 @@ +/* test_jump_page.c — 跳页状态机 + 条漫落位映射宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * J1 跳页状态:begin 夹取/非法取消、active/target、预解码就绪落位、超时降级落位; + * J2 落位映射:mr_strip_offset_for_page 与 page_at 互逆(首/中/末页,max 夹取); + * J3 边界:空册/越界/重复取消;正常等待不提前落位(不白屏——没有图就不落)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_jump_page.c logic/reader/nav.c \ + * -o test/test_jump_page && ./test/test_jump_page + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/reader/nav.h" + +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +#define VW 480 +#define VH 720 +#define TIMEOUT 2500ULL + +static int g_meta_w[8], g_meta_h[8], g_meta_n; + +static int meta_cb(void *ctx, int i, int *w, int *h) +{ + (void)ctx; + if (i < 0 || i >= g_meta_n) return 0; + *w = g_meta_w[i]; + *h = g_meta_h[i]; + return 1; +} + +static void build_strip(mr_strip *s, int pages) +{ + memset(s, 0, sizeof *s); + g_meta_n = pages; + for (int i = 0; i < pages; i++) { + g_meta_w[i] = 800; + g_meta_h[i] = 1200; /* 适宽后每页 720px */ + } + CHECK(mr_strip_build(s, pages, VW, VH, meta_cb, NULL) == 0, "build strip pages=%d", pages); +} + +static void test_jump_state(void) +{ + mr_jump j; + memset(&j, 0, sizeof j); + CHECK(mr_jump_active(&j) == 0, "fresh inactive"); + CHECK(mr_jump_target(&j) == -1, "fresh target -1"); + + /* 正常:开始 → 未就绪继续等 → 就绪落位 */ + mr_jump_begin(&j, 4, 10, 100); + CHECK(mr_jump_active(&j) == 1 && mr_jump_target(&j) == 4, "begin active target=4"); + int to = -1; + CHECK(mr_jump_poll(&j, 0, 200, TIMEOUT, &to) == 0 && to == 0, "waiting no timeout"); + CHECK(mr_jump_active(&j) == 1, "still active while waiting"); + CHECK(mr_jump_poll(&j, 1, 300, TIMEOUT, &to) == 1 && to == 0, "ready lands"); + CHECK(mr_jump_active(&j) == 0 && mr_jump_target(&j) == -1, "cleared after land"); + + /* 超时:仍然落位,但标记降级 */ + mr_jump_begin(&j, 2, 10, 1000); + CHECK(mr_jump_poll(&j, 0, 1000 + TIMEOUT - 1, TIMEOUT, &to) == 0, "just before timeout waits"); + CHECK(mr_jump_poll(&j, 0, 1000 + TIMEOUT, TIMEOUT, &to) == 1 && to == 1, "timeout lands"); + CHECK(mr_jump_active(&j) == 0, "timeout clears"); + + /* 就绪与超时同时成立:优先算正常就绪 */ + mr_jump_begin(&j, 1, 10, 0); + CHECK(mr_jump_poll(&j, 1, 999999, TIMEOUT, &to) == 1 && to == 0, + "ready wins over timeout"); + + /* timeout=0 表示不限时 */ + mr_jump_begin(&j, 1, 10, 0); + CHECK(mr_jump_poll(&j, 0, 999999999ULL, 0, &to) == 0 && to == 0, "no timeout when 0"); + + /* begin 夹取/非法 */ + mr_jump_begin(&j, -1, 10, 0); + CHECK(mr_jump_active(&j) == 0, "begin(-1) cancels"); + mr_jump_begin(&j, 10, 10, 0); + CHECK(mr_jump_active(&j) == 0, "begin(count) cancels"); + mr_jump_begin(&j, 0, 0, 0); + CHECK(mr_jump_active(&j) == 0, "begin empty book cancels"); + + /* 取消幂等;NULL 安全 */ + mr_jump_begin(&j, 3, 10, 5); + mr_jump_cancel(&j); + mr_jump_cancel(&j); + CHECK(mr_jump_active(&j) == 0, "cancel idempotent"); + CHECK(mr_jump_poll(NULL, 1, 1, TIMEOUT, &to) == 0, "poll NULL"); + mr_jump_cancel(NULL); + mr_jump_begin(NULL, 0, 10, 0); + + /* timed_out 可为 NULL */ + mr_jump_begin(&j, 5, 10, 0); + CHECK(mr_jump_poll(&j, 0, TIMEOUT + 1, TIMEOUT, NULL) == 1, "poll NULL timed_out"); +} + +static void test_land_mapping(void) +{ + mr_strip s; + build_strip(&s, 5); + CHECK(mr_strip_total(&s) == 3600, "total=%d", mr_strip_total(&s)); + CHECK(mr_strip_max_offset(&s) == 2880, "max=%d", mr_strip_max_offset(&s)); + + int expect_top[5] = { 0, 720, 1440, 2160, 2880 }; + for (int i = 0; i < 5; i++) { + int off = mr_strip_offset_for_page(&s, i); + CHECK(off == expect_top[i], "page %d offset %d (want %d)", i, off, expect_top[i]); + CHECK(mr_strip_page_at(&s, off) == i, "page_at(offset(%d)) == %d", i, i); + } + /* 末页顶端恰在 max(整页铺满视口),落位不会越界 */ + CHECK(mr_strip_offset_for_page(&s, 4) == mr_strip_max_offset(&s), "last page == max"); + /* page+1 的偏移等于 page 的末端(页间无缝) */ + CHECK(mr_strip_top(&s, 1) == mr_strip_top(&s, 0) + mr_strip_page_h(&s, 0), + "page tops contiguous"); + + /* 跳页目标页必须在解码窗里(否则会被淘汰):窗口只给 1 槽时也能覆盖目标 */ + int f = -1, l = -1; + CHECK(mr_strip_window(&s, 0, 1, &f, &l) == 1 && f == l, "single-slot window"); + mr_strip_free(&s); + + /* 空册:没有可落位目标 */ + mr_strip e; + memset(&e, 0, sizeof e); + CHECK(mr_strip_offset_for_page(&e, 0) == -1, "empty book no offset"); + CHECK(mr_strip_page_at(&e, 0) == -1, "empty book no page"); +} + +int main(void) +{ + test_jump_state(); + test_land_mapping(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_mode_persist.c b/tuku/test/test_mode_persist.c new file mode 100644 index 0000000..238f48e --- /dev/null +++ b/tuku/test/test_mode_persist.c @@ -0,0 +1,165 @@ +/* test_mode_persist.c — 阅读模式 + 每册续读状态持久化宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * P1 模式值:valid/name/parse(paged/webtoon,非法值回退); + * P2 册标识键:reader.page/mode.,末尾斜杠等价、不同册不同键、 + * 键字符集安全([A-Za-z0-9._-]); + * P3 默认值:无记录时 get_mode/get_page 返回调用方给的默认值; + * P4 读写往返 + 册间隔离 + 一次 save 写两键; + * P5 坏值/非法模式忽略(不覆盖已有记录);写失败只 WARN 不崩; + * P6 书库角标:page>0 → "续读 Pn";page==0/无记录 → 无角标。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_mode_persist.c logic/reader.c \ + * test/settings_reader_stub.c -o test/test_mode_persist + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/reader.h" +#include "test/settings_reader_stub.h" +#include "include/meow_ui_config.h" + +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static void test_mode_values(void) +{ + CHECK(mr_mode_valid(MR_MODE_PAGED) == 1, "paged valid"); + CHECK(mr_mode_valid(MR_MODE_WEBTOON) == 1, "webtoon valid"); + CHECK(mr_mode_valid(-1) == 0 && mr_mode_valid(2) == 0, "invalid modes"); + CHECK(strcmp(mr_mode_name(MR_MODE_PAGED), "paged") == 0, "name paged"); + CHECK(strcmp(mr_mode_name(MR_MODE_WEBTOON), "webtoon") == 0, "name webtoon"); + CHECK(strcmp(mr_mode_name(99), "paged") == 0, "name invalid -> paged"); + CHECK(mr_mode_parse("paged") == MR_MODE_PAGED, "parse paged"); + CHECK(mr_mode_parse("webtoon") == MR_MODE_WEBTOON, "parse webtoon"); + CHECK(mr_mode_parse("WEBTOON") == -1, "parse case sensitive"); + CHECK(mr_mode_parse("") == -1 && mr_mode_parse(NULL) == -1, "parse empty/NULL"); +} + +static void test_keys(void) +{ + char a[64], b[64], c[64]; + CHECK(mr_cont_key("/sd/book1", "page", a, (int)sizeof a) == 0, "key ok"); + CHECK(strncmp(a, "reader.page.", 12) == 0, "key prefix %s", a); + CHECK(strlen(a) == 12 + 16, "key len=%zu", strlen(a)); + for (size_t i = 12; i < strlen(a); i++) + CHECK(isxdigit((unsigned char)a[i]) != 0, "key hex char"); + CHECK(mr_cont_key("/sd/book1/", "page", b, (int)sizeof b) == 0, "key trailing slash"); + CHECK(strcmp(a, b) == 0, "trailing slash normalizes: %s vs %s", a, b); + CHECK(mr_cont_key("/sd/book1", "mode", c, (int)sizeof c) == 0, "mode key"); + CHECK(strncmp(c, "reader.mode.", 12) == 0, "mode prefix %s", c); + CHECK(strcmp(a, c) != 0, "field differs"); + CHECK(mr_cont_key("/sd/book2", "page", c, (int)sizeof c) == 0, "book2 key"); + CHECK(strcmp(a, c) != 0, "different books differ"); + + /* 参数/缓冲边界 */ + CHECK(mr_cont_key(NULL, "page", a, (int)sizeof a) == -1, "NULL dir"); + CHECK(mr_cont_key("", "page", a, (int)sizeof a) == -1, "empty dir"); + CHECK(mr_cont_key("/a", NULL, a, (int)sizeof a) == -1, "NULL field"); + CHECK(mr_cont_key("/a", "page", NULL, 32) == -1, "NULL out"); + CHECK(mr_cont_key("/a", "page", a, 8) == -1 && a[0] == '\0', "small out"); +} + +static void test_defaults_and_roundtrip(void) +{ + mr_test_cfg_clear(); + CHECK(mr_cont_get_mode("/sd/a", MR_MODE_PAGED) == MR_MODE_PAGED, "default paged"); + CHECK(mr_cont_get_mode("/sd/a", MR_MODE_WEBTOON) == MR_MODE_WEBTOON, "default param used"); + CHECK(mr_cont_get_page("/sd/a", 0) == 0, "default page 0"); + CHECK(mr_cont_get_page("/sd/a", 7) == 7, "default page param"); + CHECK(mr_cont_badge("/sd/a", NULL, 0) == 0, "badge NULL safe"); + + char badge[24]; + CHECK(mr_cont_badge("/sd/a", badge, (int)sizeof badge) == 0 && badge[0] == '\0', + "no record -> no badge"); + + mr_cont_set_mode("/sd/a", MR_MODE_WEBTOON); + CHECK(mr_cont_get_mode("/sd/a", MR_MODE_PAGED) == MR_MODE_WEBTOON, "mode roundtrip"); + CHECK(mr_cont_get_mode("/sd/b", MR_MODE_PAGED) == MR_MODE_PAGED, "mode isolated per book"); + + mr_cont_set_page("/sd/a", 17); + CHECK(mr_cont_get_page("/sd/a", -1) == 17, "page roundtrip"); + CHECK(mr_cont_get_page("/sd/b", -1) == -1, "page isolated per book"); + CHECK(mr_cont_badge("/sd/a", badge, (int)sizeof badge) == 1, "badge shown"); + CHECK(strcmp(badge, "续读 P18") == 0, "badge text %s", badge); + + /* 一次 save 两键(模式 + 页码) */ + mr_cont_save("/sd/c", 3, MR_MODE_WEBTOON); + CHECK(mr_cont_get_page("/sd/c", -1) == 3, "save page"); + CHECK(mr_cont_get_mode("/sd/c", MR_MODE_PAGED) == MR_MODE_WEBTOON, "save mode"); + CHECK(mr_cont_badge("/sd/c", badge, (int)sizeof badge) == 1 && + strcmp(badge, "续读 P4") == 0, "save badge %s", badge); + + /* page 0 视为「没有进度」,不出角标 */ + mr_cont_save("/sd/d", 0, MR_MODE_PAGED); + CHECK(mr_cont_badge("/sd/d", badge, (int)sizeof badge) == 0 && badge[0] == '\0', + "page 0 -> no badge"); + + /* NULL/空路径安全 */ + mr_cont_set_page(NULL, 5); + mr_cont_set_mode(NULL, MR_MODE_WEBTOON); + mr_cont_save(NULL, 1, 1); + mr_cont_save("", 1, 1); + CHECK(mr_cont_get_page(NULL, 9) == 9 && mr_cont_get_mode(NULL, MR_MODE_PAGED) == MR_MODE_PAGED, + "NULL dir safe"); +} + +static void test_bad_values(void) +{ + mr_test_cfg_clear(); + char key[64]; + char buf[24]; + + /* 坏模式值 → 默认;坏页码(负数/非数字/超上限)→ 默认 */ + CHECK(mr_cont_key("/sd/x", "mode", key, (int)sizeof key) == 0, "bad key"); + mr_test_cfg_put(key, "vertical"); + CHECK(mr_cont_get_mode("/sd/x", MR_MODE_PAGED) == MR_MODE_PAGED, "bad mode -> def"); + + CHECK(mr_cont_key("/sd/x", "page", key, (int)sizeof key) == 0, "bad page key"); + mr_test_cfg_put(key, "-3"); + CHECK(mr_cont_get_page("/sd/x", 11) == 11, "negative page -> def"); + mr_test_cfg_put(key, "abc"); + CHECK(mr_cont_get_page("/sd/x", 12) == 12, "non-numeric page -> def"); + mr_test_cfg_put(key, "99999999"); + CHECK(mr_cont_get_page("/sd/x", 13) == 13, "too-large page -> def"); + mr_test_cfg_put(key, "0"); + CHECK(mr_cont_get_page("/sd/x", 14) == 0, "zero page readable"); + + /* 非法模式值不覆盖已有记录 */ + mr_cont_set_mode("/sd/y", MR_MODE_WEBTOON); + mr_cont_set_mode("/sd/y", 42); + CHECK(mr_cont_get_mode("/sd/y", MR_MODE_PAGED) == MR_MODE_WEBTOON, "invalid set ignored"); + + /* 写失败:只告警,内存保持旧值;不崩 */ + mr_cont_save("/sd/z", 5, MR_MODE_PAGED); + int warns = mr_test_log_warns(); + mr_test_cfg_fail_writes(1); + mr_cont_set_page("/sd/z", 9); + mr_cont_set_mode("/sd/z", MR_MODE_WEBTOON); + mr_cont_save("/sd/z", 10, MR_MODE_WEBTOON); + CHECK(mr_test_log_warns() > warns, "write failure warns (warns=%d)", mr_test_log_warns()); + CHECK(mr_cont_get_page("/sd/z", -1) == 5, "failed write keeps old page"); + CHECK(mr_cont_get_mode("/sd/z", MR_MODE_PAGED) == MR_MODE_PAGED, "failed write keeps old mode"); + mr_test_cfg_fail_writes(0); + + /* 写入后读回同一字符串(便于排查:paged/webtoon 明文) */ + CHECK(mr_cont_key("/sd/y", "mode", key, (int)sizeof key) == 0, "read mode key"); + CHECK(mr_test_cfg_get(key, buf, (int)sizeof buf) == 1 && strcmp(buf, "webtoon") == 0, + "stored mode string %s", buf); +} + +int main(void) +{ + test_mode_values(); + test_keys(); + test_defaults_and_roundtrip(); + test_bad_values(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_mode_switch_host.c b/tuku/test/test_mode_switch_host.c new file mode 100644 index 0000000..1a7d9d8 --- /dev/null +++ b/tuku/test/test_mode_switch_host.c @@ -0,0 +1,273 @@ +/* test_mode_switch_host.c — 阅读器模式/续读/跳页/册间衔接的宿主集成测试(可选,需 SDK 源码树)。 + * + * 只做纯逻辑测试覆盖不到的接缝:真实 SDK view/activity/widget + 真实图片解码 + 真实 + * meow_scroll/meow_anim 节拍。用 40x60 BMP 当页图(stb 可解,无外部依赖),在临时目录 + * 建两本相邻册,然后模拟用户操作: + * H1 进入:默认翻页模式、页码 0、控件归属 ACT_READER(顶栏在 on_enter 建); + * H2 chrome 图标一键切模式:翻页 ⇄ 条漫,切换立即写 meow_config(每册记忆); + * H3 跳页滑块:展开 → 拖到末页 → 目标页解码完成后落位(不是先白屏再跳); + * H4 离开/重进:恢复上次页 + 模式(续读); + * H5 册间衔接:条漫末页上拉 → 下一册首页;首页下拉 → 上一册末页;首册再无上一册时 + * 停在原地(不退出、不崩)。 + * + * 编译:test/run_webtoon_tests.sh(需 SDK 源码树;只发布 meow_ui.a 时自动 SKIP)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "include/meow_ui_anim.h" +#include "meow_ui_internal.h" /* MEOW_EV_TAP / MEOW_EV_SWIPE(宿主测试内部头) */ + +#include +#include +#include +#include +#include +#include + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +/* app.c 的导航上报在测试里不需要网络侧实现。 */ +void mr_nav_report(void) {} + +static int g_lib_enters; +static void lib_enter(meow_view *v, void *c) { (void)v; (void)c; g_lib_enters++; } +static void lib_leave(meow_view *v, void *c) { (void)v; (void)c; } + +/* ---- 临时目录 + 最小 BMP24(stb 可解) ---- */ +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +static int write_bmp24(const char *path, int w, int h) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +/* 驱动 SDK 动画节拍(宿主没有 meow_ui_run 主循环)。 */ +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +/* 模拟 app.c 的点击路由:on_touch_down → on_tap(阅读页 tap 不在 view on_event 里)。 */ +static int tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + return 1; +} + +static int swipe(int dir, int dist) +{ + return meow_view_dispatch_event(MEOW_EV_SWIPE, dir, dist); +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_mode_host_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX], book1[MR_PATH_MAX], book2[MR_PATH_MAX]; + snprintf(root, sizeof root, "./mr_mode_books_%ld", (long)getpid()); + snprintf(book1, sizeof book1, "%s/vol1", root); + snprintf(book2, sizeof book2, "%s/vol2", root); + mkdir_p(book1); + mkdir_p(book2); + for (int b = 0; b < 2; b++) { + const char *dir = b ? book2 : book1; + for (int i = 0; i < 3; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%d.bmp", dir, i + 1); + if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + } + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { .on_enter = lib_enter, .on_leave = lib_leave }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + /* TopAppBar 动作格(位图):右起每格 48px;动作 0=模式 / 1=跳页 / 2=fit。 */ + int w = meow_ui_width(); + int mode_x = w - 10 - 48 * 3 + 24; + int jump_x = w - 10 - 48 * 2 + 24; + int bar_y = 28; + int slider_y = 56 + 4 + 20; + int sx = 24, sw = w - 48; + + /* ---- H1 进入:翻页模式、第 1 页 ---- */ + mr_reader_open_dir(book1); + CHECK(meow_ui_activity_active() == ACT_READER, "reader active after open"); + CHECK(mr_reader_ui_count() == 3, "book pages=%d", mr_reader_ui_count()); + CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "default mode is paged"); + CHECK(mr_reader_ui_page() == 0, "start at page 0"); + pump_ms(300); + + /* ---- H2 模式切换:chrome 图标一键切,立即持久化 ---- */ + CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon"); + CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_WEBTOON, + "webtoon mode persisted per book"); + CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed (back)"); + CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "switched back to paged"); + CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_PAGED, "paged mode persisted"); + CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed (again)"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon again for continuity"); + + /* ---- H3 跳页:滑块展开 → 拖到末页 → 预解码完成后落位 ---- */ + CHECK(tap(jump_x, bar_y) == 1, "jump icon tap consumed"); + CHECK(mr_reader_ui_jump_open() == 1, "slider open"); + reader_on_touch_down(sx + sw, slider_y); + reader_on_tap(sx + sw, slider_y); + CHECK(mr_reader_ui_jump_open() == 0, "slider closed after pick"); + CHECK(mr_reader_ui_jump_pending() == 1 || mr_reader_ui_page() == 2, + "jump pending (waiting for target decode)"); + pump_ms(600); + CHECK(mr_reader_ui_jump_pending() == 0, "jump landed"); + CHECK(mr_reader_ui_page() == 2, "jumped to last page (got %d)", mr_reader_ui_page()); + + /* ---- H4 续读:离开保存 page+mode,重进恢复 ---- */ + CHECK(swipe(MEOW_SWIPE_LEFT, 200) == 0, "reader leaves back-swipe to app.c"); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library"); + CHECK(mr_cont_get_page(book1, -1) == 2, "last page saved (got %d)", + mr_cont_get_page(book1, -1)); + CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_WEBTOON, "mode saved"); + CHECK(g_lib_enters >= 2, "library re-entered"); + + mr_reader_open_dir(book1); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "mode restored on reopen"); + CHECK(mr_reader_ui_page() == 2, "page restored on reopen (got %d)", mr_reader_ui_page()); + pump_ms(300); + + /* ---- H5 册间衔接:条漫末页上拉 → 下一册首页 ---- */ + reader_on_touch_down(240, 600); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 600 - i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_UP, 240) == 1, "webtoon up-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, "advanced to next book (got %s)", mr_reader_dir()); + CHECK(mr_reader_ui_page() == 0, "next book starts at page 0"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "mode carried into fresh book"); + pump_ms(300); + + /* 首页下拉 → 上一册末页 */ + reader_on_touch_down(240, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 300 + i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "webtoon down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book1) == 0, "back to previous book (got %s)", mr_reader_dir()); + CHECK(mr_reader_ui_page() == 2, "previous book lands at last page (got %d)", + mr_reader_ui_page()); + pump_ms(300); + + /* 跳到首页后,再往上一册:没有相邻册,停在原地并提示(不退出/不崩)。 */ + CHECK(tap(jump_x, bar_y) == 1, "jump icon tap consumed (to first page)"); + CHECK(mr_reader_ui_jump_open() == 1, "slider open again"); + reader_on_touch_down(sx + 2, slider_y); + reader_on_tap(sx + 2, slider_y); + pump_ms(500); + CHECK(mr_reader_ui_page() == 0, "jumped to first page (got %d)", mr_reader_ui_page()); + + reader_on_touch_down(240, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 300 + i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "edge down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book1) == 0, "no previous book: stay put"); + CHECK(meow_ui_activity_active() == ACT_READER, "no previous book: still in reader (no exit)"); + + /* ---- 收尾 ---- */ + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "popped back to library"); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + + rm_rf(root); + rm_rf(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "reader mode/continuity integration checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_webtoon_strip.c b/tuku/test/test_webtoon_strip.c new file mode 100644 index 0000000..9efb483 --- /dev/null +++ b/tuku/test/test_webtoon_strip.c @@ -0,0 +1,220 @@ +/* test_webtoon_strip.c — 条漫排版/滚动窗口纯逻辑宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * W1 单页:高度=适宽、总高、max_offset=0、窗口只有本页、顶/底同时成立; + * W2 多页:按页高累加 offsets;page_at 二分定位(页边界/末页/越界); + * W3 可见窗口:视口±1 页;max_pages 截断;条底/条顶窗口不越界; + * W4 尺寸兜底:未知 meta → 2:3 占位;横图适宽;极端长图夹到上限; + * W5 参数/生命周期:空册、非法视口、重复 free、重建; + * W6 跳页落位:offset_for_page 与 page_at 互逆(含末页 max 夹取)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_webtoon_strip.c logic/reader/nav.c \ + * -o test/test_webtoon_strip && ./test/test_webtoon_strip + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/reader/nav.h" + +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +#define VW 480 +#define VH 720 + +typedef struct fake_meta { + int n; + int w[16]; + int h[16]; + int known[16]; +} fake_meta; + +static int meta_cb(void *ctx, int i, int *w, int *h) +{ + fake_meta *m = (fake_meta *)ctx; + if (!m || i < 0 || i >= m->n) return 0; + *w = m->w[i]; + *h = m->h[i]; + return m->known[i]; +} + +static void meta_page(fake_meta *m, int i, int w, int h, int known) +{ + m->w[i] = w; + m->h[i] = h; + m->known[i] = known; + if (i + 1 > m->n) m->n = i + 1; +} + +static void test_single_page(void) +{ + fake_meta m; + memset(&m, 0, sizeof m); + meta_page(&m, 0, 800, 1200, 1); + + mr_strip s; + memset(&s, 0, sizeof s); + CHECK(mr_strip_build(&s, 1, VW, VH, meta_cb, &m) == 0, "build single"); + CHECK(s.count == 1, "count=%d", s.count); + CHECK(mr_strip_page_h(&s, 0) == 720, "single h=%d", mr_strip_page_h(&s, 0)); + CHECK(mr_strip_total(&s) == 720, "single total=%d", mr_strip_total(&s)); + CHECK(mr_strip_max_offset(&s) == 0, "single max=%d", mr_strip_max_offset(&s)); + CHECK(mr_strip_page_at(&s, 0) == 0, "single page at 0"); + CHECK(mr_strip_page_at(&s, 719) == 0, "single page at 719"); + CHECK(mr_strip_page_at(&s, 720) == 0, "single page at total clamps"); + CHECK(mr_strip_at_end(&s, 0) == 1, "single at end"); + CHECK(mr_strip_at_start(&s, 0) == 1, "single at start"); + + int f = -1, l = -1; + CHECK(mr_strip_window(&s, 0, 4, &f, &l) == 1 && f == 0 && l == 0, + "single window %d..%d", f, l); + CHECK(mr_strip_offset_for_page(&s, 0) == 0, "single offset page0"); + CHECK(mr_strip_offset_for_page(&s, 9) == 0, "single offset OOB clamps"); + mr_strip_free(&s); +} + +static void test_multi_pages(void) +{ + fake_meta m; + memset(&m, 0, sizeof m); + /* 0: 800x1200 -> 720;1: 未知 -> 720 占位;2: 横图 1500x1000 -> 320; + * 3: 方图 400x400 -> 480;4: 极端长图 1x2000 -> 夹到 200000。 */ + meta_page(&m, 0, 800, 1200, 1); + meta_page(&m, 1, 0, 0, 0); + meta_page(&m, 2, 1500, 1000, 1); + meta_page(&m, 3, 400, 400, 1); + meta_page(&m, 4, 1, 2000, 1); + + mr_strip s; + memset(&s, 0, sizeof s); + CHECK(mr_strip_build(&s, 5, VW, VH, meta_cb, &m) == 0, "build multi"); + CHECK(mr_strip_page_h(&s, 0) == 720, "h0=%d", mr_strip_page_h(&s, 0)); + CHECK(mr_strip_page_h(&s, 1) == 720, "h1(unknown)=%d", mr_strip_page_h(&s, 1)); + CHECK(mr_strip_page_h(&s, 2) == 320, "h2(landscape)=%d", mr_strip_page_h(&s, 2)); + CHECK(mr_strip_page_h(&s, 3) == 480, "h3(square)=%d", mr_strip_page_h(&s, 3)); + CHECK(mr_strip_page_h(&s, 4) == MR_STRIP_MAX_PAGE_H, "h4(extreme)=%d", + mr_strip_page_h(&s, 4)); + + CHECK(mr_strip_top(&s, 0) == 0, "top0=%d", mr_strip_top(&s, 0)); + CHECK(mr_strip_top(&s, 1) == 720, "top1=%d", mr_strip_top(&s, 1)); + CHECK(mr_strip_top(&s, 2) == 1440, "top2=%d", mr_strip_top(&s, 2)); + CHECK(mr_strip_top(&s, 3) == 1760, "top3=%d", mr_strip_top(&s, 3)); + CHECK(mr_strip_top(&s, 4) == 2240, "top4=%d", mr_strip_top(&s, 4)); + CHECK(mr_strip_top(&s, 5) == 202240, "top[count]=%d", mr_strip_top(&s, 5)); + CHECK(mr_strip_total(&s) == 202240, "total=%d", mr_strip_total(&s)); + CHECK(mr_strip_max_offset(&s) == 201520, "max=%d", mr_strip_max_offset(&s)); + + CHECK(mr_strip_page_at(&s, 0) == 0, "page_at 0"); + CHECK(mr_strip_page_at(&s, 719) == 0, "page_at 719"); + CHECK(mr_strip_page_at(&s, 720) == 1, "page_at 720"); + CHECK(mr_strip_page_at(&s, 1439) == 1, "page_at 1439"); + CHECK(mr_strip_page_at(&s, 1440) == 2, "page_at 1440"); + CHECK(mr_strip_page_at(&s, 1759) == 2, "page_at 1759"); + CHECK(mr_strip_page_at(&s, 1760) == 3, "page_at 1760"); + CHECK(mr_strip_page_at(&s, 2239) == 3, "page_at 2239"); + CHECK(mr_strip_page_at(&s, 2240) == 4, "page_at 2240"); + CHECK(mr_strip_page_at(&s, 9999999) == 4, "page_at huge -> last"); + CHECK(mr_strip_page_at(&s, -50) == 0, "page_at negative -> first"); + CHECK(mr_strip_page_at(&s, 0) == 0 && mr_strip_page_at(NULL, 0) == -1, "page_at NULL"); + + /* 视口窗口:off=0 只看得到页 0(页 1 从 720 开始)→ ±1 = [0,1] */ + int f = -1, l = -1; + CHECK(mr_strip_window(&s, 0, 4, &f, &l) == 2 && f == 0 && l == 1, + "window top %d..%d", f, l); + /* off=1000:可见页 1..2 → 窗口 [0,3] */ + CHECK(mr_strip_window(&s, 1000, 4, &f, &l) == 4 && f == 0 && l == 3, + "window mid %d..%d", f, l); + /* off=max:可见页 4 → 窗口 [3,4] */ + CHECK(mr_strip_window(&s, 201520, 4, &f, &l) == 2 && f == 3 && l == 4, + "window bottom %d..%d", f, l); + /* 只用 1 个槽位:窗口收缩到焦点页 */ + CHECK(mr_strip_window(&s, 1000, 1, &f, &l) == 1 && f >= 1 && f <= 2 && f == l, + "window max_pages=1 %d..%d", f, l); + CHECK(mr_strip_window(&s, -100, 4, &f, &l) == 2 && f == 0 && l == 1, + "window negative off %d..%d", f, l); + + CHECK(mr_strip_at_end(&s, 0) == 0, "not at end at 0"); + CHECK(mr_strip_at_end(&s, 201519) == 0, "not at end just before max"); + CHECK(mr_strip_at_end(&s, 201520) == 1, "at end at max"); + CHECK(mr_strip_at_start(&s, 0) == 1, "at start 0"); + CHECK(mr_strip_at_start(&s, 1) == 0, "not at start 1"); + + /* 跳页落位:每页 offset 回读一致(末页夹到 max) */ + for (int i = 0; i < 5; i++) { + int off = mr_strip_offset_for_page(&s, i); + int back = mr_strip_page_at(&s, off); + CHECK(back == i, "roundtrip page %d -> off %d -> page %d", i, off, back); + } + CHECK(mr_strip_offset_for_page(&s, 4) == 2240, "last page offset = its top"); + + /* 短册(内容不足一屏):任何页都夹到 max=0 */ + fake_meta m2; + memset(&m2, 0, sizeof m2); + meta_page(&m2, 0, 800, 300, 1); /* 高 180 */ + meta_page(&m2, 1, 800, 300, 1); + mr_strip s2; + memset(&s2, 0, sizeof s2); + CHECK(mr_strip_build(&s2, 2, VW, VH, meta_cb, &m2) == 0, "build short"); + CHECK(mr_strip_total(&s2) == 360 && mr_strip_max_offset(&s2) == 0, "short totals"); + CHECK(mr_strip_offset_for_page(&s2, 1) == 0, "short last page clamps to 0"); + CHECK(mr_strip_at_end(&s2, 0) == 1 && mr_strip_at_start(&s2, 0) == 1, "short edges"); + mr_strip_free(&s2); + + /* 越界参数 */ + CHECK(mr_strip_page_h(&s, -1) == 0 && mr_strip_page_h(&s, 5) == 0, "page_h OOB"); + CHECK(mr_strip_top(&s, -1) == 0 && mr_strip_top(&s, 6) == 0, "top OOB"); + + mr_strip_free(&s); + CHECK(s.top == NULL && s.height == NULL && s.count == 0, "free clears"); + mr_strip_free(&s); +} + +static void test_build_contract(void) +{ + fake_meta m; + memset(&m, 0, sizeof m); + meta_page(&m, 0, 800, 1200, 1); + + mr_strip s; + memset(&s, 0, sizeof s); + CHECK(mr_strip_build(NULL, 1, VW, VH, meta_cb, &m) == -1, "NULL strip"); + CHECK(mr_strip_build(&s, 0, VW, VH, meta_cb, &m) == -1, "count 0 -> -1"); + CHECK(s.top == NULL && s.count == 0, "failed build clears"); + CHECK(mr_strip_build(&s, 1, 0, VH, meta_cb, &m) == -1, "vw 0 -> -1"); + CHECK(mr_strip_build(&s, 1, VW, 0, meta_cb, &m) == -1, "vh 0 -> -1"); + + /* meta 回调为 NULL → 全部按 2:3 占位 */ + CHECK(mr_strip_build(&s, 2, VW, VH, NULL, NULL) == 0, "NULL meta build"); + CHECK(mr_strip_page_h(&s, 0) == 720 && mr_strip_page_h(&s, 1) == 720, + "NULL meta fallback heights"); + mr_strip_free(&s); + + /* meta 返回失败(0)也按占位处理 */ + CHECK(mr_strip_build(&s, 1, VW, VH, meta_cb, &m) == 0, "rebuild after free"); + CHECK(mr_strip_page_h(&s, 0) == 720, "rebuild height"); + mr_strip_free(&s); + + /* 空排版查询全部安全 */ + mr_strip e; + memset(&e, 0, sizeof e); + CHECK(mr_strip_total(&e) == 0 && mr_strip_max_offset(&e) == 0, "empty totals"); + CHECK(mr_strip_page_at(&e, 0) == -1, "empty page_at"); + CHECK(mr_strip_offset_for_page(&e, 0) == -1, "empty offset_for_page"); + int f = 9, l = 9; + CHECK(mr_strip_window(&e, 0, 4, &f, &l) == 0 && l == -1, "empty window"); + CHECK(mr_strip_at_end(&e, 0) == 0 && mr_strip_at_start(&e, 0) == 0, "empty edges"); +} + +int main(void) +{ + test_single_page(); + test_multi_pages(); + test_build_contract(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 19f9b87..ae80fe2 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -1,27 +1,40 @@ -/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页模式。 +/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页 + 条漫连续滚动。 * - * 用户口径:优先流畅度,其次功能。翻页路径不解码、不重排: - * - 后台 pthread 解码窗口(当前 ±1 页),主循环只取已备好的图; - * - 跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms); - * - 只 flush 受影响区(本页=整屏内容区);翻页/解码/驻留耗时都 meow_log 打点。 + * 用户口径:优先流畅度,其次功能。两条阅读路径都不在翻页/滚动热路径上解码: + * - 后台 pthread 解码窗口(视口 ±1 页,总驻留 ≤ MR_SLOTS 张 downscale 后页图), + * 主循环只取已备好的图; + * - 翻页:跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms); + * - 条漫:meow_scroll 跟手 track + 松手 fling 惯性,按滚动位置懒解码视口 ±1 页; + * - 只 flush 受影响区(本页/整条可见区);解码/驻留耗时都 meow_log 打点。 * - * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。目录册 - * (文件夹即书)是第一个实现;zip 册与条漫只需另写 provider / 消费方式,本页零改动。 + * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。条漫排版用 + * meta() 不解码像素;解码入口走 SDK M12 预算解码 meow_img_load_file_dim(JPEG 解码期缩解, + * PNG/BMP 整幅解码后降采样——超大头先 meow_img_probe_file 预判,超过预算跳过并告警)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * + * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page + * 经 meow_config 持久化(键 reader.mode/page.<册标识>,见 logic/reader.c),下次打开同一册 + * 自动恢复。书库行角标由 logic/library.c 扫描时读取同一份状态(副标题后缀「续读 Pn」)。 + * + * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; + * 2.5s 超时则降级落位并提示)。 + * + * 册间衔接:末页前进 → 同父目录下一个含图册(书库自然序)的首页;首页后退 → 上一册末页; + * 无相邻册时停住并轻提示(不误触退出)。条漫在条顶/条底继续拉 MR_BOOK_PULL 像素松手触发。 + * * 手势(与 SDK 语义对齐,见 sdk/meow_ui_input.c touch_release): * - 左缘(x<24)向右划 = 系统返回(MEOW_SWIPE_LEFT)→ 不消费,app.c page_pop 退出; * - 屏底 28px 上滑 = 系统回桌面(MEOW_EV_HOME),SDK 不分发给 App,本页不起手; - * - 中部横拖 = 跟手翻页(本页用 app.c 路由来的原始 down/move 自己算位移); - * - tap 分区方向感知:屏左/右 1/3 = 上/下一页,中 1/3 = chrome 显隐;双击 = 放大。 - * - 物理键不映射(app.c 只记日志)。 + * - 翻页模式:中部横拖 = 跟手翻页;tap 分区方向感知(左/右 1/3 = 上/下一页,中 1/3 = + * chrome 显隐);双击 = 放大。 + * - 条漫模式:竖向拖动 = 整条滚动(fling 惯性);tap = chrome 显隐;双击/横向滑动不动作。 * * 节拍:app.c 的 on_tick 只转给 library_on_tick(不在本页 scope)。本页用一条常驻 - * meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取与双击判定超时, - * 不新增常驻轮询线程,也不改 on_tick 路由。翻页动画本身也走同一节拍。 + * meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取、跳页落位、续读节流 + * 保存与单击判定超时;翻页动画与 meow_scroll fling 也走同一节拍。 * - * 生命周期:on_enter 建状态 + 建 TopAppBar + 预取首页;on_leave 全量释放(停解码线程、 - * 释放全部页图/册索引/控件)。控件在 on_enter 建 → owner=ACT_READER(非根页防泄漏)。 + * 生命周期:on_enter 建状态 + 建 TopAppBar/滑块 + 打开册并恢复模式/页码;on_leave 先落盘 + * 续读状态,再全量释放(停解码线程、释放页图/册索引/条漫排版/滚动会话/控件)。 */ #include "tuku_internal.h" #include "ui/reader/reader.h" @@ -32,9 +45,11 @@ #include #include #include +#include -#define MR_WIN 3 /* 预解码窗口:当前 ±1 页 */ -#define MR_TOPBAR_H 56 +#define MR_SLOTS 4 /* 页图缓存槽上限(条漫视口±1 ≤4;翻页只用 3) */ +#define MR_TOP_BAR_H 56 +#define MR_SLIDER_H 40 #define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */ #define MR_BOUNCE_MS 120 /* 未过阈值回弹 */ #define MR_DOUBLE_MS 240 /* 单击等待窗口(区分双击) */ @@ -43,6 +58,12 @@ #define MR_PAINT_MS 16 /* 跟手拖动重绘节流(≈60fps 上限) */ #define MR_DRAG_SLOP 8 /* 小于此位移算点击 */ #define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) /* 缩放临时缓冲软上限(>此值降倍率,保内存) */ +#define MR_BOOK_PULL 110 /* 条漫到顶/底后再拉这么远,松手进相邻册 */ +#define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ +#define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ +#define MR_TOAST_MS 1400 +#define MR_LOAD_RAW_WARN (12L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ +#define MR_LOAD_RAW_CAP (40L * 1024 * 1024) /* 超此值直接跳过(防 OOM;页显示占位) */ #define MR_REQ_MAX 8 #define MR_RES_MAX 4 @@ -57,7 +78,8 @@ static mr_dir_book g_db; static mr_book g_book; static int g_count; /* 0=空册/不可读 */ static int g_open_err; -static int g_page; +static int g_page; /* 翻页模式当前页;条漫模式=最近落位页 */ +static int g_mode = MR_MODE_PAGED; static int g_vw, g_vh; /* 内容视口 = 全屏 */ static int g_fit = MR_FIT_WIDTH; @@ -65,18 +87,63 @@ static int g_zoom_pct = 100; static int g_pan_x, g_pan_y; static int g_chrome = 1; static meow_widget *g_tb; +static int g_act_mode = -1; /* TopAppBar 动作下标:模式切换 */ +static int g_act_jump = -1; /* 动作下标:跳页滑块 */ +static int g_act_fit = -1; /* 动作下标:fit 档循环 */ -/* ---- 页图缓存(≤3 张 downscale 后的图)---- */ +/* ---- 条漫:排版表 + 滚动会话 ---- */ +static mr_strip g_strip; +static int g_strip_ok; +static int g_woff; /* 条漫滚动偏移(px) */ +static meow_scroll *g_scroll; + +/* ---- 跳页(chrome 滑块 + 预解码落位) ---- */ +static meow_widget *g_slider; +static int g_jump_open; +static mr_jump g_jump; + +/* ---- 轻提示(册边界/模式切换/跳页) ---- */ +static char g_toast[64]; +static uint64_t g_toast_until; +static int g_title_page = -1; /* 标题里显示的页(条漫滚动停止后同步) */ + +/* ---- 续读落盘(节流) ---- */ +static int g_cont_dirty; +static int g_cont_last_page = -1; +static int g_cont_last_mode = -1; +static uint64_t g_cont_save_ms; + +/* ---- 当前解码窗(≤MR_SLOTS 页,按优先级排列) ---- */ +static int g_win_idx[MR_SLOTS]; +static int g_win_n; +static int g_fail_paint; /* 窗口内出现解码失败页 → tick 重绘错误占位 */ + +/* ---- 页图缓存(≤4 张 downscale 后的图)---- */ typedef struct page_slot { int idx; /* -1=空 */ meow_img *img; /* 已 downscale 的页图 */ int src_w, src_h; } page_slot; -static page_slot g_slot[MR_WIN]; +static page_slot g_slot[MR_SLOTS]; + +/* 解码失败页标记:避免对坏图/超大图无限重试,UI 显示「图片无法显示」而不是一直加载中。 */ +static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8]; + +static int page_failed(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0; + return (g_page_fail[idx >> 3] >> (idx & 7)) & 1; +} + +static void page_mark_failed(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return; + g_page_fail[idx >> 3] |= (unsigned char)(1u << (idx & 7)); +} /* ---- 后台解码队列 ---- */ typedef struct page_req { int idx; unsigned gen; } page_req; -typedef struct page_res { int idx; unsigned gen; meow_img *img; } page_res; +typedef struct page_res { int idx; unsigned gen; meow_img *img; int fail; } page_res; static pthread_t g_worker; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; @@ -103,11 +170,14 @@ static int g_quitting; /* on_leave 期间抑制回调 */ /* ---- 跟手拖动 ---- */ static int g_dragging; /* 1=本次按下正在拖动 */ -static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 */ +static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 3=滑块 4=条漫滚动 */ static int g_drag_x0, g_drag_y0; static int g_drag_off; /* 当前页水平位移(px) */ static int g_drag_pan_x0, g_drag_pan_y0; static int g_touch_moved; /* 本次按下是否已产生 >slop 的位移 */ +static int g_wo_last_y; /* 条漫:上一次 move 的 y */ +static int g_wo_y0; /* 条漫:按下点 y(slop 判定) */ +static int g_wo_pull; /* 条漫:到边后继续拉的累计量(>0 往末页方向) */ /* ---- 单击延迟(双击判定窗口)---- */ static int g_tap_pending; @@ -116,16 +186,59 @@ static uint64_t g_tap_ms; static uint64_t g_last_tap_ms; /* 上一次点击(跨按下保持,双击判定用) */ static int g_last_tap_x, g_last_tap_y; +/* ---- 前向声明(互相调用:册切换/模式/落位) ---- */ +static int open_book(const char *dir, int start_page, int mode); +static int book_switch(int delta); +static void mode_set(int mode, int from_page); +static void toast(const char *msg); +static void slider_place(void); +static void update_title(void); +static void cont_touch(void); +static void cont_save_now(void); +static int current_page(void); + /* ============================================================ */ /* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ /* ============================================================ */ +/* 扩展名判断:JPEG 支持解码期缩解(M12),PNG/BMP 只能整幅解码后降采样。 */ +static int path_is_jpeg(const char *path) +{ + const char *dot = path ? strrchr(path, '.') : NULL; + if (!dot) return 0; + dot++; + return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0; +} + static meow_img *reader_load_page(const char *path, void *ud, int maxdim) { (void)ud; - meow_img *im = meow_img_load_file(path); + if (!path || !path[0]) return NULL; + + /* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。 + * - 非缩解格式(PNG/BMP)原始 RGBA 超过预算 → 跳过,避免真机 OOM(页面显示占位); + * - 超过告警线 → 记 WARN(残余风险,按 M12 报告口径如实提示)。 */ + int pw = 0, ph = 0, ch = 0; + if (meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0) { + long long raw = (long long)pw * (long long)ph * 4; + if (!path_is_jpeg(path)) { + if (raw > MR_LOAD_RAW_CAP) { + meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (raw %lldKB > cap): %s", + pw, ph, raw / 1024, path); + return NULL; + } + if (raw > MR_LOAD_RAW_WARN) + meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s", + pw, ph, raw / 1024, path); + } + } + + meow_img *im = meow_img_load_file_dim(path, maxdim); if (!im) return NULL; - meow_img *small = meow_img_downscale(im, maxdim); /* 大图先缩再驻留 */ - if (small) { meow_img_free(im); im = small; } + /* 兜底:M12 之前的格式/未知格式若没被缩到预算内,再降一次(保持驻留纪律)。 */ + if (maxdim > 0 && (meow_img_w(im) > maxdim || meow_img_h(im) > maxdim)) { + meow_img *small = meow_img_downscale(im, maxdim); + if (small) { meow_img_free(im); im = small; } + } return im; } @@ -154,7 +267,7 @@ static long mem_kb(const char *path, const char *key) static int resident_pages(void) { int n = 0; - for (int i = 0; i < MR_WIN; i++) + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].img) n++; return n; } @@ -162,7 +275,7 @@ static int resident_pages(void) static long resident_bytes(void) { long b = 0; - for (int i = 0; i < MR_WIN; i++) + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].img) b += (long)g_slot[i].src_w * g_slot[i].src_h * 4; return b; } @@ -174,20 +287,77 @@ static void log_mem(const char *tag) mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); } +/* ============================================================ */ +/* 续读状态(逐册 last page + mode;写入节流,避免滚动时频繁写卡) */ +/* ============================================================ */ +static int current_page(void) +{ + if (g_count <= 0) return 0; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) + return mr_strip_page_at(&g_strip, g_woff); + return g_page; +} + +static void cont_save_now(void) +{ + if (!g_dir[0] || g_count <= 0) { g_cont_dirty = 0; return; } + int p = current_page(); + if (p == g_cont_last_page && g_mode == g_cont_last_mode) { g_cont_dirty = 0; return; } + mr_cont_save(g_dir, p, g_mode); + g_cont_last_page = p; + g_cont_last_mode = g_mode; + g_cont_dirty = 0; + meow_log(MEOW_LOG_INFO, "reader cont save page=%d/%d mode=%s", + p + 1, g_count, mr_mode_name(g_mode)); +} + +static void cont_touch(void) +{ + g_cont_dirty = 1; +} + +/* 只在滚动/翻页停下且距上次写盘超过 MR_CONT_SAVE_MS 时落盘。 */ +static void cont_save_throttled(void) +{ + if (!g_cont_dirty) return; + if (g_scroll && meow_scroll_active(g_scroll)) return; + if (g_dragging) return; + uint64_t now = meow_ui_time_ms(); + if (now - g_cont_save_ms < MR_CONT_SAVE_MS) return; + g_cont_save_ms = now; + cont_save_now(); +} + /* ============================================================ */ /* 页图缓存 */ /* ============================================================ */ +static int focus_page(void) +{ + if (g_count <= 0) return -1; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) + return mr_strip_page_at(&g_strip, g_woff + g_vh / 2); /* 视口中心页 */ + return g_page; +} + +static int in_window(int idx) +{ + if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; + for (int i = 0; i < g_win_n; i++) + if (g_win_idx[i] == idx) return 1; + return 0; +} + static page_slot *slot_find(int idx) { if (idx < 0) return NULL; - for (int i = 0; i < MR_WIN; i++) + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].idx == idx && g_slot[i].img) return &g_slot[i]; return NULL; } static void slot_clear(int i) { - if (i < 0 || i >= MR_WIN) return; + if (i < 0 || i >= MR_SLOTS) return; if (g_slot[i].img) meow_img_free(g_slot[i].img); g_slot[i].img = NULL; g_slot[i].idx = -1; @@ -196,18 +366,19 @@ static void slot_clear(int i) static void slots_free_all(void) { - for (int i = 0; i < MR_WIN; i++) slot_clear(i); + for (int i = 0; i < MR_SLOTS; i++) slot_clear(i); } -/* 放进缓存;缓存满时淘汰离当前页最远的一张。返回 1=已放入。 */ +/* 放进缓存;缓存满时淘汰离焦点页最远的一张。返回 1=已放入。 */ static int slot_put(int idx, meow_img *img) { if (!img || idx < 0) return 0; + int focus = focus_page(); int empty = -1, far = -1, far_d = -1; - for (int i = 0; i < MR_WIN; i++) { + for (int i = 0; i < MR_SLOTS; i++) { if (g_slot[i].idx == idx && g_slot[i].img) { meow_img_free(img); return 0; } if (!g_slot[i].img && empty < 0) empty = i; - int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - g_page); + int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - focus); if (d > far_d) { far_d = d; far = i; } } int i = empty >= 0 ? empty : far; @@ -220,12 +391,50 @@ static int slot_put(int idx, meow_img *img) return 1; } -/* 当前窗口:正常=当前±1;放大时只驻留当前页(给缩放临时缓冲让内存)。 */ -static int in_window(int idx) +/* ============================================================ */ +/* 解码窗计算(≤MR_SLOTS 页,按优先级:跳页目标 > 焦点 > 视口 ±1) */ +/* ============================================================ */ +static void win_reset(void) { - if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; - if (g_zoom_pct > 100) return idx == g_page; - return idx >= g_page - 1 && idx <= g_page + 1; + g_win_n = 0; +} + +static void win_add(int idx) +{ + if (idx < 0 || idx >= g_count || g_win_n >= MR_SLOTS) return; + for (int i = 0; i < g_win_n; i++) + if (g_win_idx[i] == idx) return; + g_win_idx[g_win_n++] = idx; +} + +static void window_compute(void) +{ + win_reset(); + if (g_count <= 0) return; + + int jt = mr_jump_target(&g_jump); + if (jt >= 0) win_add(jt); /* 跳页目标优先,否则会被窗口淘汰 */ + + int focus = focus_page(); + win_add(focus); + + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + int f = 0, l = 0; + int budget = MR_SLOTS - g_win_n; /* 可见±1;剩余槽位给视口 */ + if (budget < 1) budget = 1; + mr_strip_window(&g_strip, g_woff, budget, &f, &l); + for (int i = f; i <= l; i++) win_add(i); + /* 视口窗口可能因预算被截:再补焦点两侧各一页,保证滚动方向有前瞻 */ + for (int d = 1; d <= MR_SLOTS; d++) { + win_add(focus - d); + win_add(focus + d); + } + } else if (g_zoom_pct > 100) { + win_add(g_page); /* 放大:只驻留当前页,给缩放临时缓冲让内存 */ + } else { + win_add(g_page - 1); + win_add(g_page + 1); + } } /* ============================================================ */ @@ -243,7 +452,7 @@ static int req_queued(int idx) static void queue_page(int idx) { if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - if (slot_find(idx)) return; + if (slot_find(idx) || page_failed(idx)) return; pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { g_req[g_req_tail].idx = idx; @@ -277,12 +486,13 @@ static void *reader_worker(void *arg) req.idx, (unsigned long long)dt, w, h, img ? "ok" : "fail"); pthread_mutex_lock(&g_lock); - if (img && !g_stop && g_res_n < MR_RES_MAX) { + if (!g_stop && g_res_n < MR_RES_MAX) { g_res[g_res_n].idx = req.idx; g_res[g_res_n].gen = req.gen; g_res[g_res_n].img = img; + g_res[g_res_n].fail = img ? 0 : 1; g_res_n++; - img = NULL; + img = NULL; /* 所有权已移交(失败时本就是 NULL) */ } pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); @@ -345,7 +555,13 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { - if (got[i].gen == g_gen && in_window(got[i].idx) && !slot_find(got[i].idx)) { + if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } + if (got[i].fail) { + page_mark_failed(got[i].idx); + if (in_window(got[i].idx)) g_fail_paint = 1; + continue; + } + if (in_window(got[i].idx) && !slot_find(got[i].idx)) { if (slot_put(got[i].idx, got[i].img)) changed = 1; } else { meow_img_free(got[i].img); @@ -354,16 +570,88 @@ static int results_drain(void) return changed; } -/* 窗口随当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 补齐缺失页(当前页优先)。 */ +/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ +/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ static void window_update(void) { - for (int i = 0; i < MR_WIN; i++) + window_compute(); + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].idx >= 0 && !in_window(g_slot[i].idx)) slot_clear(i); queue_prune(); + for (int i = 0; i < g_win_n; i++) queue_page(g_win_idx[i]); +} - queue_page(g_page); - if (g_page - 1 >= 0) queue_page(g_page - 1); - if (g_page + 1 < g_count) queue_page(g_page + 1); +/* ============================================================ */ +/* 条漫排版 + 滚动会话 */ +/* ============================================================ */ +static int strip_meta_cb(void *ctx, int index, int *w, int *h) +{ + return mr_book_meta((const mr_book *)ctx, index, w, h); +} + +static int strip_ensure(void) +{ + if (g_strip_ok && g_strip.count == g_count && + g_strip.vw == g_vw && g_strip.vh == g_vh) + return 1; + mr_strip_free(&g_strip); + g_strip_ok = 0; + if (g_count <= 0) return 0; + uint64_t t0 = meow_ui_time_ms(); + if (mr_strip_build(&g_strip, g_count, g_vw, g_vh, strip_meta_cb, &g_book) != 0) { + meow_log(MEOW_LOG_WARN, "reader strip layout failed pages=%d", g_count); + return 0; + } + g_strip_ok = 1; + meow_log(MEOW_LOG_INFO, "reader strip layout pages=%d total=%d ms=%llu", + g_count, mr_strip_total(&g_strip), + (unsigned long long)(meow_ui_time_ms() - t0)); + return 1; +} + +static int wo_max(void *ctx) +{ + (void)ctx; + return mr_strip_max_offset(&g_strip); +} + +static int wo_get(void *ctx) +{ + (void)ctx; + return g_woff; +} + +static void wo_set(void *ctx, int off) +{ + (void)ctx; + int m = mr_strip_max_offset(&g_strip); + if (off < 0) off = 0; + if (off > m) off = m; + if (off == g_woff) return; + g_woff = off; + window_update(); + cont_touch(); + /* 重绘由调用方节流:拖动走 drag_paint(),fling 走 wo_redraw()。 */ +} + +static void wo_redraw(void *ctx) +{ + (void)ctx; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void scroll_ensure(void) +{ + if (g_scroll) return; + g_scroll = meow_scroll_create(wo_set, wo_max, wo_get, wo_redraw, NULL); + if (!g_scroll) meow_log(MEOW_LOG_WARN, "reader scroll session unavailable (pool full)"); +} + +static void scroll_free(void) +{ + if (!g_scroll) return; + meow_scroll_free(g_scroll); + g_scroll = NULL; } /* ============================================================ */ @@ -427,6 +715,50 @@ static void draw_page_at(int idx, int off_x, int off_y) else meow_ui_img_scaled(x, y, w, h, s->img); } +/* 条漫:按滚动偏移画可见页。典型 2:3 页的解码尺寸与显示尺寸相等 → 原生 blit; + * 其余按最近邻缩放(缩放在 SDK 内部申请目标尺寸临时缓冲,单页峰值受控)。 */ +static void draw_strip(const meow_theme *th) +{ + if (!g_strip_ok) { + meow_ui_text_center(g_vw / 2, g_vh / 2, "正在排版…", th->font_body, th->onSurfaceVariant); + return; + } + int first = mr_strip_page_at(&g_strip, g_woff); + int last = mr_strip_page_at(&g_strip, g_woff + g_vh - 1); + for (int i = first; i <= last; i++) { + int y = mr_strip_top(&g_strip, i) - g_woff; + int h = mr_strip_page_h(&g_strip, i); + if (h <= 0) continue; + page_slot *s = slot_find(i); + if (s) { + if (s->src_w == g_vw && s->src_h == h) meow_ui_img(0, y, s->img); + else meow_ui_img_scaled(0, y, g_vw, h, s->img); + continue; + } + /* 未就绪:只填可见部分(长页不整块清屏),给一帧占位而不是白屏。 */ + int y0 = y < 0 ? 0 : y; + int y1 = y + h; + if (y1 > g_vh) y1 = g_vh; + if (y1 <= y0) continue; + meow_ui_fill_rect(0, y0, g_vw, y1 - y0, th->surfaceContainer); + if (y1 - y0 >= 48) + meow_ui_text_center(g_vw / 2, y0 + (y1 - y0) / 2 - 10, + page_failed(i) ? "图片无法显示" : "加载中…", + th->font_body, th->onSurfaceVariant); + } +} + +static void draw_toast(const meow_theme *th) +{ + if (!g_toast[0] || meow_ui_time_ms() >= g_toast_until) return; + int tw = meow_ui_text_width(g_toast, th->font_body) + 40; + if (tw > g_vw - 16) tw = g_vw - 16; + int x = (g_vw - tw) / 2; + int y = g_vh - 120; + meow_ui_fill_rect_alpha(x, y, tw, 44, th->surfaceContainerHighest, 235); + meow_ui_text_center(g_vw / 2, y + 12, g_toast, th->font_body, th->onSurface); +} + static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) { (void)v; (void)ctx; @@ -444,10 +776,17 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) return; } + if (g_mode == MR_MODE_WEBTOON) { + draw_strip(th); + draw_toast(th); + return; + } + if (g_slide_delta != 0) { /* 动画帧:进场页从 from 滑到 0,当前页同步让位(只 blit 两张已缓存页图)。 */ draw_page_at(g_page, g_anim_v - g_slide_from, 0); draw_page_at(g_page + g_slide_delta, g_anim_v, 0); + draw_toast(th); return; } @@ -461,13 +800,17 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) draw_page_at(target, g_drag_off + from, 0); } draw_page_at(g_page, g_drag_off, 0); + draw_toast(th); return; } draw_page_at(g_page, 0, 0); if (!slot_find(g_page)) { - meow_ui_text_center(w / 2, h / 2, "加载中…", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, h / 2, + page_failed(g_page) ? "图片无法显示" : "加载中…", + th->font_body, th->onSurfaceVariant); } + draw_toast(th); } /* ============================================================ */ @@ -479,16 +822,66 @@ static void update_title(void) static const char *const fit_name[MR_FIT_COUNT] = { "适宽", "适高", "原始" }; const char *fit = (g_fit >= 0 && g_fit < MR_FIT_COUNT) ? fit_name[g_fit] : "适宽"; char buf[64]; - if (g_count > 0) snprintf(buf, sizeof buf, "%d / %d %s", g_page + 1, g_count, fit); - else snprintf(buf, sizeof buf, "0 / 0"); + if (g_count > 0) { + if (g_mode == MR_MODE_WEBTOON) + snprintf(buf, sizeof buf, "%d / %d 条漫", current_page() + 1, g_count); + else + snprintf(buf, sizeof buf, "%d / %d 翻页 %s", g_page + 1, g_count, fit); + } else { + snprintf(buf, sizeof buf, "0 / 0"); + } meow_topappbar_set_title(g_tb, buf); + g_title_page = (g_count > 0) ? current_page() : -1; +} + +static void toast(const char *msg) +{ + snprintf(g_toast, sizeof g_toast, "%s", msg ? msg : ""); + g_toast_until = meow_ui_time_ms() + MR_TOAST_MS; + meow_ui_invalidate(0, g_vh - 140, g_vw, 140); +} + +static void slider_place(void) +{ + if (!g_slider) return; + int visible = g_chrome && g_jump_open && g_count > 1; + if (visible) + meow_widget_set_rect(g_slider, 24, MR_TOP_BAR_H + 4, g_vw - 48, MR_SLIDER_H); + else + meow_widget_set_rect(g_slider, 0, -(MR_TOP_BAR_H + MR_SLIDER_H + 8), + g_vw - 48, MR_SLIDER_H); +} + +static void slider_build(void) +{ + if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } + if (g_count <= 1) return; + int val = current_page() + 1; + if (val < 1) val = 1; + if (val > g_count) val = g_count; + g_slider = meow_slider_create(0, 0, g_vw - 48, 1, g_count, val); + slider_place(); +} + +static void slider_sync(void) +{ + if (!g_slider || g_count <= 0) return; + int v = current_page() + 1; + if (v < 1) v = 1; + if (v > g_count) v = g_count; + meow_slider_set(g_slider, v); } static void set_fit(int fit) { + if (g_mode == MR_MODE_WEBTOON) { + toast("条漫固定适宽"); + return; + } g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; + window_update(); update_title(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -498,8 +891,9 @@ static void set_chrome(int on) on = on ? 1 : 0; if (g_chrome == on) return; g_chrome = on; - if (g_tb) meow_widget_set_rect(g_tb, 0, on ? 0 : -MR_TOPBAR_H, g_vw, MR_TOPBAR_H); - meow_ui_invalidate(0, 0, g_vw, MR_TOPBAR_H); + if (g_tb) meow_widget_set_rect(g_tb, 0, on ? 0 : -MR_TOP_BAR_H, g_vw, MR_TOP_BAR_H); + slider_place(); + meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8); } /* 平移(dx>0 = 内容右移);放大或原始档溢出时可用。 */ @@ -521,6 +915,8 @@ static void commit_page(int target) g_pan_x = g_pan_y = 0; window_update(); update_title(); + slider_sync(); + cont_touch(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -605,12 +1001,15 @@ static void start_bounce(int from_off) if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; meow_ui_invalidate(0, 0, g_vw, g_vh); } } -/* 点击触发的翻页(无跟手位移)。 */ +/* 点击触发的翻页(无跟手位移)。首/末页再前进 → 相邻册(无相邻册则轻提示)。 */ static void page_flip(int delta) { if (delta == 0 || g_count <= 0) return; int target = mr_nav_clamp_page(g_page + delta, g_count); - if (target == g_page) return; /* 首/末页不动 */ + if (target == g_page) { /* 首/末页:册间衔接 */ + book_switch(delta > 0 ? 1 : -1); + return; + } if (g_snap || g_bounce) return; /* 短动画中忽略连点 */ if (g_zoom_pct != 100) { /* 翻页回到基准缩放 */ g_zoom_pct = 100; @@ -630,6 +1029,234 @@ static void page_flip(int delta) } } +/* ============================================================ */ +/* 跳页(chrome 滑块):目标页预解码完成后落位 */ +/* ============================================================ */ +static void jump_land(int page, int timed_out) +{ + if (g_count <= 0) return; + page = mr_nav_clamp_page(page, g_count); + if (page < 0) page = 0; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + if (g_scroll) meow_scroll_stop(g_scroll); + g_woff = mr_strip_offset_for_page(&g_strip, page); + g_page = page; + } else { + g_page = page; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + } + slider_sync(); + window_update(); + update_title(); + cont_touch(); + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader jump land page=%d/%d%s", + page + 1, g_count, timed_out ? " (timeout)" : ""); + if (timed_out) toast("页面解码较慢,已跳到目标页"); + else if (page_failed(page)) toast("目标页无法显示"); +} + +static void jump_commit_from_slider(void) +{ + if (!g_slider || g_count <= 0) return; + int page = meow_slider_get(g_slider) - 1; + g_jump_open = 0; + slider_place(); + if (page < 0) page = 0; + if (page >= g_count) page = g_count - 1; + if (page == current_page()) { /* 原地:只关面板 */ + meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8); + return; + } + if (slot_find(page)) { /* 已缓存:直接落位 */ + jump_land(page, 0); + return; + } + mr_jump_begin(&g_jump, page, g_count, meow_ui_time_ms()); + window_update(); /* 目标页进解码窗(最高优先级) */ + char msg[48]; + snprintf(msg, sizeof msg, "跳转到 P%d…", page + 1); + toast(msg); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void jump_ui_toggle(void) +{ + if (g_count <= 1) { toast("只有一页,无需跳转"); return; } + g_jump_open = !g_jump_open; + if (g_jump_open) { + if (!g_slider) slider_build(); + slider_sync(); + } + slider_place(); + meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8); +} + +static void slider_scrub(int x) +{ + if (!g_slider || g_count <= 1) return; + int sx = 24, sw = g_vw - 48; + if (sw < 1) sw = 1; + int v = 1 + (int)((long long)(x - sx) * (g_count - 1) / sw); + if (v < 1) v = 1; + if (v > g_count) v = g_count; + meow_slider_set(g_slider, v); + meow_ui_invalidate(0, MR_TOP_BAR_H, g_vw, MR_SLIDER_H + 8); +} + +/* ============================================================ */ +/* 模式切换(翻页 ⇄ 条漫) */ +/* ============================================================ */ +static void mode_set(int mode, int from_page) +{ + if (!mr_mode_valid(mode) || mode == g_mode) return; + int page = from_page >= 0 ? from_page : current_page(); + page = mr_nav_clamp_page(page, g_count); + if (page < 0) page = 0; + + if (mode == MR_MODE_WEBTOON) { + if (!strip_ensure()) { toast("条漫排版失败,保持翻页"); return; } + scroll_ensure(); + g_page = page; + g_woff = mr_strip_offset_for_page(&g_strip, page); + if (g_scroll) meow_scroll_stop(g_scroll); + } else { + g_page = page; + if (g_scroll) { scroll_free(); } + mr_strip_free(&g_strip); + g_strip_ok = 0; + g_woff = 0; + } + g_mode = mode; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + slider_sync(); + window_update(); + update_title(); + cont_touch(); + cont_save_now(); /* 模式切换立即记忆 */ + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader mode=%s page=%d/%d", + mr_mode_name(g_mode), current_page() + 1, g_count); + toast(mode == MR_MODE_WEBTOON ? "条漫模式 · 连续滚动" : "翻页模式"); +} + +/* ============================================================ */ +/* 册间衔接 / 打开册 */ +/* ============================================================ */ +static void open_book_reset_state(void) +{ + worker_stop(); + scroll_free(); + mr_strip_free(&g_strip); + g_strip_ok = 0; + slots_free_all(); + mr_book_destroy(&g_book); + mr_dir_book_free(&g_db); + if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } + g_gen++; /* 作废旧册的异步结果 */ + g_count = 0; + g_open_err = 0; + g_page = 0; + g_woff = 0; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + g_cont_dirty = 0; + g_cont_last_page = -1; + g_cont_last_mode = -1; + g_win_n = 0; + g_fail_paint = 0; + memset(g_page_fail, 0, sizeof g_page_fail); + g_jump_open = 0; + mr_jump_cancel(&g_jump); + g_toast[0] = '\0'; + g_title_page = -1; +} + +/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ +static int open_book(const char *dir, int start_page, int mode) +{ + if (!dir || !dir[0]) return 0; + open_book_reset_state(); + snprintf(g_dir, sizeof g_dir, "%s", dir); + g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; + + int maxdim = g_vw > g_vh ? g_vw : g_vh; + int n = mr_dir_book_init(&g_db, g_dir, maxdim, + reader_load_page, reader_unload_page, NULL); + if (n < 0) { + mr_dir_book_bind(&g_book, NULL); + g_open_err = 1; + worker_start(); + update_title(); + meow_log(MEOW_LOG_WARN, "reader open failed: %s", g_dir); + meow_ui_invalidate(0, 0, g_vw, g_vh); + return 0; + } + g_count = n; + mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); + + int page = start_page < 0 ? g_count - 1 : start_page; + page = mr_nav_clamp_page(page, g_count); + if (page < 0) page = 0; + + if (g_mode == MR_MODE_WEBTOON) { + if (!strip_ensure()) { /* 排版失败:降级翻页,不白屏 */ + meow_log(MEOW_LOG_WARN, "reader strip unavailable, fallback paged"); + g_mode = MR_MODE_PAGED; + } + } + if (g_mode == MR_MODE_WEBTOON) { + scroll_ensure(); + g_page = page; + g_woff = mr_strip_offset_for_page(&g_strip, page); + } else { + g_page = page; + g_woff = 0; + } + + worker_start(); + if (!g_worker_on && g_count > 0) { + /* 线程创建失败:退化为主线程同步解起始页(保证不是纯白屏)。 */ + meow_img *first = mr_book_open(&g_book, g_page); + if (first) slot_put(g_page, first); + meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); + } + slider_build(); + window_update(); /* 起始页优先进队 */ + update_title(); + cont_touch(); + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader book=%s pages=%d mode=%s page=%d", + g_dir, g_count, mr_mode_name(g_mode), current_page() + 1); + return 1; +} + +static int book_switch(int delta) +{ + if (delta == 0) return 0; + if (g_count <= 0) return 0; + char next[MR_PATH_MAX]; + int r = mr_book_adjacent(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next); + if (r != 1) { + toast(delta > 0 ? "已是最后一册" : "已是第一册"); + return 0; + } + cont_save_now(); /* 先记忆当前册 */ + int mode = mr_cont_get_mode(next, g_mode); /* 相邻册自己的模式;没有则沿用当前 */ + if (!open_book(next, delta > 0 ? 0 : -1, mode)) { + toast("无法打开相邻册"); + return 0; + } + const char *base = strrchr(g_dir, '/'); + char msg[64]; + snprintf(msg, sizeof msg, "%s · %s", delta > 0 ? "下一册" : "上一册", + (base && base[1]) ? base + 1 : g_dir); + toast(msg); + return 1; +} + /* ============================================================ */ /* 跟手拖动 */ /* ============================================================ */ @@ -659,7 +1286,12 @@ static void drag_finish(void) int rtl = mr_reader_get_direction() == MR_DIR_RTL; int delta = mr_nav_swipe_delta(rtl, dx); int target = mr_nav_clamp_page(g_page + delta, g_count); - if (target != g_page && mr_nav_commit(dx, g_vw)) { + if (mr_nav_commit(dx, g_vw)) { + if (target == g_page) { /* 首/末页越界:进相邻册 */ + book_switch(delta > 0 ? 1 : -1); + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } g_flip_t0 = meow_ui_time_ms(); if (slot_find(target) && start_slide(delta, dx)) { meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); @@ -675,15 +1307,49 @@ static void drag_finish(void) } } +/* 条漫松手:到条顶/条底后再拉过阈值 → 进相邻册;否则起 fling 惯性。 */ +static void webtoon_release(void) +{ + int pull = g_wo_pull; + g_wo_pull = 0; + g_dragging = 0; + g_drag_mode = 0; + if (pull >= MR_BOOK_PULL && book_switch(1)) return; + if (pull <= -MR_BOOK_PULL && book_switch(-1)) return; + if (g_scroll) meow_scroll_fling(g_scroll); + cont_touch(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + void reader_on_touch_down(int x, int y) { if (!g_ctrl.active || g_quitting) return; if (g_count <= 0) return; + g_touch_moved = 0; /* 每次按下都是新手势(chrome 抬手也不残留) */ if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ - if (g_chrome && y < MR_TOPBAR_H) return; /* chrome 上的点击交给 tap */ + if (g_chrome && y < MR_TOP_BAR_H) + return; /* 顶栏点击交给 tap(返回/模式/跳页/fit) */ + if (g_chrome && g_jump_open && g_slider && y < MR_TOP_BAR_H + 4 + MR_SLIDER_H) { + g_dragging = 1; + g_drag_mode = 3; + g_touch_moved = 1; + slider_scrub(x); + return; + } if (g_snap || g_bounce) return; /* 短动画中不接新手势 */ + if (g_mode == MR_MODE_WEBTOON) { + g_dragging = 1; + g_drag_mode = 4; + g_wo_y0 = y; + g_wo_last_y = y; + g_wo_pull = 0; + g_tap_pending = 0; /* 条漫没有双击缩放:单击立即生效 */ + if (g_scroll) meow_scroll_stop(g_scroll); + return; + } + g_dragging = 1; g_drag_mode = pan_mode() ? 2 : 1; g_drag_x0 = x; @@ -691,12 +1357,44 @@ void reader_on_touch_down(int x, int y) g_drag_off = 0; g_drag_pan_x0 = g_pan_x; g_drag_pan_y0 = g_pan_y; - g_touch_moved = 0; } void reader_on_touch_move(int x, int y) { if (!g_ctrl.active || !g_dragging || g_quitting) return; + + if (g_drag_mode == 3) { /* 跳页滑块拖动 */ + slider_scrub(x); + return; + } + + if (g_drag_mode == 4) { /* 条漫:整条滚动 */ + int dy = g_wo_last_y - y; /* 手指上移 → 内容上滚 */ + g_wo_last_y = y; + if (!g_touch_moved && abs(y - g_wo_y0) >= MR_DRAG_SLOP) g_touch_moved = 1; + if (!g_scroll) return; + int maxo = mr_strip_max_offset(&g_strip); + int before = g_woff; + meow_scroll_track(g_scroll, dy, meow_ui_time_ms()); + int after = g_woff; + /* 到边后继续同向拉:累计「越界意图」,松手据此进相邻册。 + * 到底后手指继续上移(dy>0)才是「前进」;到顶后继续下移(dy<0)是「后退」。 + * 内容不足一屏(maxo==0)时两向都可拉:按手指方向累计。 */ + int at_bottom = (before >= maxo && after >= maxo); + int at_top = (before <= 0 && after <= 0); + if (at_bottom && at_top) { + g_wo_pull += dy; + } else if (at_bottom) { + if (dy > 0) g_wo_pull += dy; + } else if (at_top) { + if (dy < 0) g_wo_pull += dy; + } else { + g_wo_pull = 0; + } + drag_paint(); + return; + } + int dx = x - g_drag_x0; int dy = y - g_drag_y0; int adx = abs(dx), ady = abs(dy); @@ -744,10 +1442,15 @@ static void tap_topbar(int x, int y) if (!g_tb) return; int hit = meow_topappbar_hit(g_tb, x, y); if (hit == MEOW_TOPBAR_BACK) { + cont_save_now(); meow_view_page_pop(); return; } - if (hit >= 0) set_fit(mr_nav_fit_next(g_fit)); + if (hit < 0) return; + if (hit == g_act_mode) { mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON + : MR_MODE_PAGED, -1); return; } + if (hit == g_act_jump) { jump_ui_toggle(); return; } + if (hit == g_act_fit) { set_fit(mr_nav_fit_next(g_fit)); return; } } static void tap_content(int x, int y) @@ -770,6 +1473,10 @@ static void tap_content(int x, int y) static void tap_double(int x, int y) { if (g_count <= 0) return; + if (g_mode == MR_MODE_WEBTOON) { /* 条漫不做缩放(保持适宽/内存纪律) */ + set_chrome(!g_chrome); + return; + } if (g_zoom_pct > 100) { g_zoom_pct = 100; g_pan_x = g_pan_y = 0; @@ -794,7 +1501,7 @@ static void tap_flush(void) if (!g_tap_pending) return; g_tap_pending = 0; int x = g_tap_x, y = g_tap_y; - if (g_chrome && y >= 0 && y < MR_TOPBAR_H) tap_topbar(x, y); + if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) tap_topbar(x, y); else tap_content(x, y); } @@ -802,6 +1509,35 @@ void reader_on_tap(int x, int y) { if (!g_ctrl.active || g_quitting) return; + /* 跳页滑块松手:落位到目标页(目标页预解码后由 tick 落位)。 */ + if (g_drag_mode == 3) { + g_dragging = 0; + g_drag_mode = 0; + g_touch_moved = 0; + jump_commit_from_slider(); + return; + } + + if (g_mode == MR_MODE_WEBTOON) { + if (g_dragging) { + if (g_touch_moved) { + g_touch_moved = 0; + webtoon_release(); + return; + } + g_dragging = 0; + g_drag_mode = 0; + } else if (g_touch_moved) { + g_touch_moved = 0; /* 竖向拖动被 SDK 报成 TAP:忽略 */ + return; + } + g_touch_moved = 0; + if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) { tap_topbar(x, y); return; } + if (g_jump_open && g_slider && y < MR_TOP_BAR_H + 4 + MR_SLIDER_H) return; + set_chrome(!g_chrome); + return; + } + /* 跟手手势收尾:移动过就按拖动处理(过阈值翻页 / 否则回弹),不再当点击。 */ if (g_dragging) { if (g_touch_moved) { @@ -819,7 +1555,7 @@ void reader_on_tap(int x, int y) g_touch_moved = 0; /* chrome 上的点击即时生效,不参与双击判定。 */ - if (g_chrome && y >= 0 && y < MR_TOPBAR_H) { + if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) { g_tap_pending = 0; g_last_tap_ms = 0; tap_topbar(x, y); @@ -846,7 +1582,7 @@ void reader_on_tap(int x, int y) } /* ============================================================ */ -/* 节拍:收结果 + 单击超时 */ +/* 节拍:收结果 + 跳页落位 + 提示超时 + 续读落盘 */ /* ============================================================ */ static void reader_tick(void) { @@ -855,11 +1591,36 @@ static void reader_tick(void) if (now - g_last_tick < MR_TICK_MS) return; g_last_tick = now; - int had_current = slot_find(g_page) != NULL; - if (results_drain() && !had_current && slot_find(g_page) && !g_dragging) + int had_current = slot_find(current_page()) != NULL; + if (results_drain() && !had_current && slot_find(current_page()) && !g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + if (g_fail_paint) { + g_fail_paint = 0; + if (!g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + } if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush(); + + if (mr_jump_active(&g_jump)) { + int target = mr_jump_target(&g_jump); + int have = target >= 0 && (slot_find(target) != NULL || page_failed(target)); + int timed_out = 0; + if (mr_jump_poll(&g_jump, have, now, MR_JUMP_TIMEOUT_MS, &timed_out)) + jump_land(target, timed_out); + } + + if (g_toast[0] && now >= g_toast_until) { + g_toast[0] = '\0'; + meow_ui_invalidate(0, g_vh - 140, g_vw, 140); + } + + /* 条漫滚动停止后同步标题页码(滚动中不每帧更新顶栏文字)。 */ + if (g_mode == MR_MODE_WEBTOON && !g_dragging && + (!g_scroll || !meow_scroll_active(g_scroll))) { + if (current_page() != g_title_page) update_title(); + } + + cont_save_throttled(); } static void ticker_update(meow_anim *a, float value, void *ctx) @@ -901,8 +1662,22 @@ static int reader_event(meow_view *v, int ev, int a, int b, void *ctx) g_drag_mode = 0; g_drag_off = 0; g_touch_moved = 0; + g_wo_pull = 0; + cont_save_now(); /* 离开前记忆 */ return 0; } + if (g_drag_mode == 3) { /* 滑块拖动被 SDK 报成 SWIPE:落位 */ + g_dragging = 0; + g_drag_mode = 0; + g_touch_moved = 0; + jump_commit_from_slider(); + return 1; + } + if (g_mode == MR_MODE_WEBTOON) { + /* 条漫:竖向滑动 = 滚动收尾(fling/册边界);横向滑动不翻页,统一消费。 */ + if (g_dragging) webtoon_release(); + return 1; + } if (a == MEOW_SWIPE_RIGHT) { if (g_dragging) { drag_finish(); return 1; } if (g_count <= 0) return 1; @@ -930,6 +1705,7 @@ static void reader_state_reset(void) g_count = 0; g_open_err = 0; g_page = 0; + g_mode = MR_MODE_PAGED; g_fit = MR_FIT_WIDTH; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; @@ -941,6 +1717,17 @@ static void reader_state_reset(void) g_drag_mode = 0; g_drag_off = 0; g_touch_moved = 0; + g_wo_pull = 0; + g_woff = 0; + g_strip_ok = 0; + g_jump_open = 0; + mr_jump_cancel(&g_jump); + g_toast[0] = '\0'; + g_title_page = -1; + g_cont_dirty = 0; + g_cont_last_page = -1; + g_cont_last_mode = -1; + g_win_n = 0; g_tap_pending = 0; g_last_tap_ms = 0; g_quitting = 0; @@ -969,43 +1756,31 @@ static void reader_page_enter(meow_view *v, void *ctx) g_last_paint = 0; /* 非根页:控件在 on_enter 建 → owner=ACT_READER(§6.1 跨页泄漏防线)。 */ - if (!g_tb) g_tb = meow_topappbar_create(0, 0, g_vw, MR_TOPBAR_H, ""); - if (g_tb) { - meow_topappbar_set_back(g_tb, 1); - int ai = meow_topappbar_add_action(g_tb, ""); - if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_REFRESH); - meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOPBAR_H); + if (!g_tb) { + g_tb = meow_topappbar_create(0, 0, g_vw, MR_TOP_BAR_H, ""); + if (g_tb) { + meow_topappbar_set_back(g_tb, 1); + g_act_mode = meow_topappbar_add_action(g_tb, "模式"); + if (g_act_mode >= 0) meow_topappbar_set_item_icon(g_tb, g_act_mode, MEOW_ICON_QUEUE_MUSIC); + g_act_jump = meow_topappbar_add_action(g_tb, "跳页"); + if (g_act_jump >= 0) meow_topappbar_set_item_icon(g_tb, g_act_jump, MEOW_ICON_SEARCH); + g_act_fit = meow_topappbar_add_action(g_tb, "适宽"); + if (g_act_fit >= 0) meow_topappbar_set_item_icon(g_tb, g_act_fit, MEOW_ICON_REFRESH); + } } + if (g_tb) meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOP_BAR_H); - /* 页源:目录册(文件夹即书)。maxdim 取屏幕长边,页图驻留 ≈ 屏宽×屏高×4。 */ - int maxdim = g_vw > g_vh ? g_vw : g_vh; - int n = mr_dir_book_init(&g_db, g_dir, maxdim, - reader_load_page, reader_unload_page, NULL); - if (n < 0) { - g_open_err = 1; - meow_log(MEOW_LOG_WARN, "reader open failed: %s", g_dir); - } else { - g_count = n; - } - mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); + /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ + int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); + int page = mr_cont_get_page(g_dir, 0); + open_book(g_dir, page, mode); - worker_start(); - if (!g_worker_on && g_count > 0) { - /* 线程创建失败:退化为主线程同步解第一页(保证不是纯白屏)。 */ - meow_img *first = mr_book_open(&g_book, 0); - if (first) slot_put(0, first); - meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); - } - window_update(); /* 当前页优先进队 */ ticker_start(); - update_title(); - - meow_log(MEOW_LOG_INFO, "reader enter dir=%s pages=%d fit=%d", g_dir, g_count, g_fit); log_mem("enter"); mr_nav_report(); meow_ui_paint_full(); - /* 首页解码是异步的:先给一帧文字/页码,结果在 tick 里落位。 */ + /* 起始页解码是异步的:先给一帧文字/页码,结果在 tick 里落位。 */ reader_tick(); } @@ -1015,20 +1790,30 @@ static void reader_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; g_quitting = 1; + cont_save_now(); /* 离开前把最后一页落盘 */ + if (g_snap) { meow_anim_cancel(g_snap); g_snap = NULL; } if (g_bounce) { meow_anim_cancel(g_bounce); g_bounce = NULL; } if (g_ticker) { meow_anim_cancel(g_ticker); g_ticker = NULL; } worker_stop(); /* 先停线程,再释放册/图 */ + scroll_free(); + mr_strip_free(&g_strip); + g_strip_ok = 0; slots_free_all(); mr_book_destroy(&g_book); mr_dir_book_free(&g_db); + if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + g_act_mode = g_act_jump = g_act_fit = -1; g_dragging = 0; g_drag_mode = 0; g_drag_off = 0; + g_wo_pull = 0; + g_jump_open = 0; + mr_jump_cancel(&g_jump); g_tap_pending = 0; g_slide_delta = 0; g_slide_from = 0; @@ -1064,3 +1849,12 @@ void mr_reader_open_dir(const char *path) meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); meow_view_page_push(ACT_READER); } + +#ifdef MR_READER_HOST_TEST +/* 宿主集成测试观察口(不改变任何状态)。 */ +int mr_reader_ui_mode(void) { return g_mode; } +int mr_reader_ui_page(void) { return current_page(); } +int mr_reader_ui_count(void) { return g_count; } +int mr_reader_ui_jump_open(void) { return g_jump_open; } +int mr_reader_ui_jump_pending(void) { return mr_jump_active(&g_jump); } +#endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 62c7cc3..411981b 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -13,6 +13,15 @@ extern "C" { void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); +#ifdef MR_READER_HOST_TEST +/* 宿主集成测试专用:只读观察本页状态(不参与产品逻辑)。 */ +int mr_reader_ui_mode(void); /* MR_MODE_PAGED / MR_MODE_WEBTOON */ +int mr_reader_ui_page(void); /* 当前页(条漫=视口顶端所在页) */ +int mr_reader_ui_count(void); /* 当前册页数 */ +int mr_reader_ui_jump_open(void); /* 1=跳页滑块已展开 */ +int mr_reader_ui_jump_pending(void);/* 1=跳页目标等待解码 */ +#endif + #ifdef __cplusplus } #endif From fbd4db3aececa77664372445339c19f2a98d9745 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 19:58:50 +0800 Subject: [PATCH 09/38] =?UTF-8?q?feat(tuku):=20zip=20=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=99=A8=E6=A0=B8=E5=BF=83=EF=BC=88=E7=BA=AF?= =?UTF-8?q?=20C=20+=20zlib=EF=BC=8Ccentral=20directory=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C/=E5=8D=95=E6=9D=A1=E7=9B=AE=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E8=A7=A3=E5=8E=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tuku/logic/zip/zip.c | 493 +++++++++++++++++++++++++++++ tuku/logic/zip/zip.h | 86 +++++ tuku/test/make_zip_samples.py | 160 ++++++++++ tuku/test/run_zip_tests.sh | 9 + tuku/test/test_zip.c | 570 ++++++++++++++++++++++++++++++++++ 5 files changed, 1318 insertions(+) create mode 100644 tuku/logic/zip/zip.c create mode 100644 tuku/logic/zip/zip.h create mode 100644 tuku/test/make_zip_samples.py create mode 100755 tuku/test/run_zip_tests.sh create mode 100644 tuku/test/test_zip.c diff --git a/tuku/logic/zip/zip.c b/tuku/logic/zip/zip.c new file mode 100644 index 0000000..dd60b39 --- /dev/null +++ b/tuku/logic/zip/zip.c @@ -0,0 +1,493 @@ +/* zip.c — zip 容器读取器实现,见 zip.h 的用法/线程/安全说明。 */ +#define _POSIX_C_SOURCE 200809L +#define _FILE_OFFSET_BITS 64 + +#include "zip.h" + +#include +#include +#include +#include +#include + +#define MZ_SIG_LOCAL 0x04034b50u +#define MZ_SIG_CD 0x02014b50u +#define MZ_SIG_EOCD 0x06054b50u +#define MZ_LOCAL_MIN 30u +#define MZ_CD_MIN 46u +#define MZ_EOCD_MIN 22u +#define MZ_COMMENT_MAX 65535u +#define MZ_IN_CHUNK 65536u +#define MZ_CRC_CHUNK (1u << 24) + +struct mz_archive { + FILE *fp; + uint64_t file_size; + int count; + mz_entry *entries; + char *names; +}; + +static uint16_t rd16(const uint8_t *p) +{ + return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); +} + +static uint32_t rd32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static int u64_add(uint64_t a, uint64_t b, uint64_t *out) +{ + if (a > UINT64_MAX - b) return 0; + *out = a + b; + return 1; +} + +static int seek_to(FILE *fp, uint64_t off) +{ + off_t o = (off_t)off; + if ((uint64_t)o != off) return MZ_ERR_UNSUPPORTED; + return fseeko(fp, o, SEEK_SET) == 0 ? MZ_OK : MZ_ERR_IO; +} + +static int read_exact(FILE *fp, void *buf, size_t len) +{ + if (fread(buf, 1, len, fp) == len) return MZ_OK; + return ferror(fp) ? MZ_ERR_IO : MZ_ERR_TRUNCATED; +} + +static int read_at(FILE *fp, uint64_t off, void *buf, size_t len) +{ + int rc = seek_to(fp, off); + if (rc != MZ_OK) return rc; + return read_exact(fp, buf, len); +} + +static int get_file_size(FILE *fp, uint64_t *size) +{ + if (fseeko(fp, 0, SEEK_END) != 0) return MZ_ERR_IO; + off_t end = ftello(fp); + if (end < 0) return MZ_ERR_IO; + *size = (uint64_t)end; + return MZ_OK; +} + +/* 从文件尾向前找 EOCD 签名;只接受注释长度不越过文件尾的候选。 */ +static int find_eocd(FILE *fp, uint64_t file_size, uint64_t *eocd_off, + uint8_t eocd[MZ_EOCD_MIN]) +{ + if (file_size < MZ_EOCD_MIN) return MZ_ERR_TRUNCATED; + + uint64_t tail_len = file_size; + if (tail_len > (uint64_t)MZ_EOCD_MIN + MZ_COMMENT_MAX) + tail_len = (uint64_t)MZ_EOCD_MIN + MZ_COMMENT_MAX; + uint64_t tail_off = file_size - tail_len; + + uint8_t *tail = malloc((size_t)tail_len); + if (!tail) return MZ_ERR_MEM; + int rc = read_at(fp, tail_off, tail, (size_t)tail_len); + if (rc == MZ_OK) { + rc = MZ_ERR_FORMAT; + for (uint64_t i = tail_len - MZ_EOCD_MIN + 1; i-- > 0; ) { + if (rd32(tail + i) != MZ_SIG_EOCD) continue; + uint64_t pos = tail_off + i; + uint64_t end = 0; + if (!u64_add(pos, MZ_EOCD_MIN, &end)) continue; + if (!u64_add(end, rd16(tail + i + 20), &end)) continue; + if (end > file_size) continue; + memcpy(eocd, tail + i, MZ_EOCD_MIN); + *eocd_off = pos; + rc = MZ_OK; + break; + } + } + free(tail); + return rc; +} + +/* 解析 central directory;名字先指向 cd 缓冲,成功后在 names_out 里做一份连续副本。 */ +static int parse_cd(const uint8_t *cd, uint32_t cd_size, int declared, + mz_entry *entries, char **names_out) +{ + const uint8_t *p = cd; + const uint8_t *end = cd + cd_size; + uint64_t names_total = 0; + int n = 0; + int rc = MZ_OK; + + while (n < declared) { + if ((size_t)(end - p) < MZ_CD_MIN || rd32(p) != MZ_SIG_CD) { + rc = MZ_ERR_FORMAT; + break; + } + uint16_t flags = rd16(p + 8); + uint16_t method = rd16(p + 10); + uint32_t crc = rd32(p + 16); + uint32_t comp = rd32(p + 20); + uint32_t uncomp = rd32(p + 24); + uint16_t nlen = rd16(p + 28); + uint16_t elen = rd16(p + 30); + uint16_t clen = rd16(p + 32); + uint16_t disk = rd16(p + 34); + uint32_t loff = rd32(p + 42); + uint64_t rec = (uint64_t)MZ_CD_MIN + nlen + elen + clen; + + if (rec > (uint64_t)(end - p)) { + rc = MZ_ERR_FORMAT; + break; + } + if (disk != 0) { + rc = MZ_ERR_UNSUPPORTED; + break; + } + if (comp == 0xFFFFFFFFu || uncomp == 0xFFFFFFFFu || loff == 0xFFFFFFFFu) { + rc = MZ_ERR_UNSUPPORTED; + break; + } + + mz_entry *e = &entries[n]; + e->name = (const char *)(p + MZ_CD_MIN); + e->name_len = nlen; + e->method = method; + e->flags = flags; + e->crc = crc; + e->comp_size = comp; + e->uncomp_size = uncomp; + e->local_offset = loff; + + names_total += (uint64_t)nlen + 1; + n++; + p += (size_t)rec; + } + + if (rc == MZ_OK) { + if (n != declared) { + rc = MZ_ERR_FORMAT; + } else if (names_total > (uint64_t)SIZE_MAX) { + rc = MZ_ERR_MEM; + } else { + char *arena = malloc((size_t)names_total); + if (!arena) { + rc = MZ_ERR_MEM; + } else { + char *w = arena; + for (int i = 0; i < n; i++) { + memcpy(w, entries[i].name, entries[i].name_len); + w[entries[i].name_len] = '\0'; + entries[i].name = w; + w += entries[i].name_len + 1; + } + *names_out = arena; + } + } + } + return rc; +} + +static int load_cd(mz_archive *z, uint64_t cd_off, uint32_t cd_size, int declared) +{ + uint8_t *cd = malloc(cd_size); + if (!cd) return MZ_ERR_MEM; + z->entries = calloc((size_t)declared, sizeof *z->entries); + if (!z->entries) { + free(cd); + return MZ_ERR_MEM; + } + + int rc = read_at(z->fp, cd_off, cd, cd_size); + if (rc == MZ_OK) rc = parse_cd(cd, cd_size, declared, z->entries, &z->names); + if (rc == MZ_OK) { + z->count = declared; + for (int i = 0; i < declared; i++) { + uint64_t end = 0; + if (!u64_add(z->entries[i].local_offset, MZ_LOCAL_MIN, &end) || + end > z->file_size) { + rc = MZ_ERR_TRUNCATED; + break; + } + } + } + free(cd); + return rc; +} + +int mz_open(const char *path, mz_archive **out) +{ + if (!out) return MZ_ERR_ARG; + *out = NULL; + if (!path) return MZ_ERR_ARG; + + mz_archive *z = calloc(1, sizeof *z); + if (!z) return MZ_ERR_MEM; + z->fp = fopen(path, "rb"); + if (!z->fp) { + free(z); + return MZ_ERR_IO; + } + + int rc = get_file_size(z->fp, &z->file_size); + uint8_t eocd[MZ_EOCD_MIN]; + uint64_t eocd_off = 0; + uint16_t n_total = 0; + uint32_t cd_size = 0; + uint32_t cd_off = 0; + + if (rc == MZ_OK) rc = find_eocd(z->fp, z->file_size, &eocd_off, eocd); + if (rc == MZ_OK) { + uint16_t disk_no = rd16(eocd + 4); + uint16_t cd_disk = rd16(eocd + 6); + uint16_t n_disk = rd16(eocd + 8); + n_total = rd16(eocd + 10); + cd_size = rd32(eocd + 12); + cd_off = rd32(eocd + 16); + + if (disk_no != 0 || cd_disk != 0) { + rc = MZ_ERR_UNSUPPORTED; + } else if (n_total == 0xFFFFu || n_disk == 0xFFFFu || + cd_size == 0xFFFFFFFFu || cd_off == 0xFFFFFFFFu) { + rc = MZ_ERR_UNSUPPORTED; /* zip64:EOCD 字段饱和 */ + } else if (n_disk != n_total) { + rc = MZ_ERR_FORMAT; + } else { + uint64_t cd_end = 0; + if (!u64_add(cd_off, cd_size, &cd_end) || cd_end > eocd_off) { + rc = MZ_ERR_TRUNCATED; + } else if (n_total > 0 && (uint64_t)cd_size < (uint64_t)n_total * MZ_CD_MIN) { + rc = MZ_ERR_FORMAT; + } else if ((uint64_t)cd_size > (uint64_t)SIZE_MAX) { + rc = MZ_ERR_UNSUPPORTED; + } + } + } + if (rc == MZ_OK && n_total > 0) rc = load_cd(z, cd_off, cd_size, n_total); + + if (rc != MZ_OK) { + free(z->entries); + free(z->names); + fclose(z->fp); + free(z); + return rc; + } + *out = z; + return MZ_OK; +} + +void mz_close(mz_archive *z) +{ + if (!z) return; + if (z->fp) fclose(z->fp); + free(z->entries); + free(z->names); + free(z); +} + +int mz_count(const mz_archive *z) +{ + return z ? z->count : 0; +} + +const mz_entry *mz_entry_at(const mz_archive *z, int index) +{ + if (!z || index < 0 || index >= z->count) return NULL; + return &z->entries[index]; +} + +const mz_entry *mz_find(const mz_archive *z, const char *name) +{ + if (!z || !name) return NULL; + for (int i = 0; i < z->count; i++) { + if (strcmp(z->entries[i].name, name) == 0) return &z->entries[i]; + } + return NULL; +} + +int mz_entry_is_dir(const mz_entry *e) +{ + return e && e->name_len > 0 && e->name[e->name_len - 1] == '/'; +} + +static uint32_t crc_buf(const uint8_t *p, size_t n) +{ + uLong c = crc32(0L, Z_NULL, 0); + while (n > 0) { + uInt chunk = n > MZ_CRC_CHUNK ? MZ_CRC_CHUNK : (uInt)n; + c = crc32(c, p, chunk); + p += chunk; + n -= chunk; + } + return (uint32_t)c; +} + +/* 从 data_off 流式 inflate comp_size 字节到 buf(容量 buf_cap = 声明解压尺寸)。 + * 输出多于声明尺寸或流提前结束都算 FORMAT/TRUNCATED,不整块读取压缩数据。 */ +static int inflate_entry(FILE *fp, uint64_t data_off, uint64_t comp_size, + uint8_t *buf, size_t buf_cap, size_t *out_len) +{ + z_stream z; + memset(&z, 0, sizeof z); + if (inflateInit2(&z, -MAX_WBITS) != Z_OK) return MZ_ERR_MEM; + + uint8_t *in = malloc(MZ_IN_CHUNK); + if (!in) { + inflateEnd(&z); + return MZ_ERR_MEM; + } + + int rc = seek_to(fp, data_off); + size_t produced = 0; + uint64_t consumed = 0; + uint8_t scratch = 0; + int stalls = 0; + + while (rc == MZ_OK) { + if (z.avail_in == 0) { + if (consumed >= comp_size) { + rc = MZ_ERR_TRUNCATED; + break; + } + uint64_t remain = comp_size - consumed; + size_t want = remain > MZ_IN_CHUNK ? MZ_IN_CHUNK : (size_t)remain; + rc = read_exact(fp, in, want); + if (rc != MZ_OK) break; + z.next_in = in; + z.avail_in = (uInt)want; + consumed += want; + } + + uInt avail; + if (produced < buf_cap) { + size_t room = buf_cap - produced; + z.next_out = buf + produced; + avail = room > UINT_MAX ? UINT_MAX : (uInt)room; + } else { + z.next_out = &scratch; /* 声明尺寸之外的输出 → 直接判坏流 */ + avail = 1; + } + z.avail_out = avail; + + uInt in_before = z.avail_in; + int zr = inflate(&z, Z_NO_FLUSH); + size_t made = (size_t)avail - (size_t)z.avail_out; + + if (made > buf_cap - produced) { + rc = MZ_ERR_FORMAT; + break; + } + produced += made; + + if (zr == Z_STREAM_END) break; + if (zr == Z_OK) { + if (made == 0 && z.avail_in == in_before) { + if (++stalls > 8 || (consumed >= comp_size && z.avail_in == 0)) { + rc = MZ_ERR_TRUNCATED; + break; + } + } else { + stalls = 0; + } + continue; + } + if (zr == Z_BUF_ERROR && z.avail_in == 0 && consumed >= comp_size) { + rc = MZ_ERR_TRUNCATED; + break; + } + rc = MZ_ERR_FORMAT; + break; + } + + inflateEnd(&z); + free(in); + if (rc == MZ_OK) *out_len = produced; + return rc; +} + +int mz_read(mz_archive *z, const mz_entry *e, void *buf, size_t buf_size, + size_t *out_size) +{ + if (out_size) *out_size = 0; + if (!z || !z->fp || !e) return MZ_ERR_ARG; + if (!buf && buf_size > 0) return MZ_ERR_ARG; + if (e->flags & 1u) return MZ_ERR_UNSUPPORTED; + if (e->method != 0 && e->method != 8) return MZ_ERR_UNSUPPORTED; + if (e->uncomp_size > (uint64_t)SIZE_MAX) return MZ_ERR_UNSUPPORTED; + + size_t need = (size_t)e->uncomp_size; + if (need > buf_size) return MZ_ERR_BUF; + + uint8_t lh[MZ_LOCAL_MIN]; + int rc = read_at(z->fp, e->local_offset, lh, sizeof lh); + if (rc != MZ_OK) return rc; + if (rd32(lh) != MZ_SIG_LOCAL) return MZ_ERR_FORMAT; + if (rd16(lh + 8) != e->method) return MZ_ERR_FORMAT; + + uint64_t data_off = 0; + uint64_t data_end = 0; + if (!u64_add(e->local_offset, MZ_LOCAL_MIN, &data_off) || + !u64_add(data_off, rd16(lh + 26), &data_off) || + !u64_add(data_off, rd16(lh + 28), &data_off) || + !u64_add(data_off, e->comp_size, &data_end) || + data_end > z->file_size) { + return MZ_ERR_TRUNCATED; + } + + if (e->method == 0) { + if (e->comp_size != e->uncomp_size) return MZ_ERR_FORMAT; + if (need > 0) { + rc = read_at(z->fp, data_off, buf, need); + if (rc != MZ_OK) return rc; + } + if (crc_buf((const uint8_t *)buf, need) != e->crc) return MZ_ERR_CRC; + } else { + size_t made = 0; + rc = inflate_entry(z->fp, data_off, e->comp_size, (uint8_t *)buf, need, &made); + if (rc != MZ_OK) return rc; + if (made != need) return MZ_ERR_FORMAT; + if (crc_buf((const uint8_t *)buf, made) != e->crc) return MZ_ERR_CRC; + } + + if (out_size) *out_size = need; + return MZ_OK; +} + +int mz_read_alloc(mz_archive *z, const mz_entry *e, void **out_buf, + size_t *out_size) +{ + if (!out_buf || !out_size) return MZ_ERR_ARG; + *out_buf = NULL; + *out_size = 0; + if (!z || !e) return MZ_ERR_ARG; + if (e->uncomp_size > (uint64_t)SIZE_MAX) return MZ_ERR_UNSUPPORTED; + + size_t need = (size_t)e->uncomp_size; + uint8_t *p = malloc(need ? need : 1); + if (!p) return MZ_ERR_MEM; + + size_t n = 0; + int rc = mz_read(z, e, p, need, &n); + if (rc != MZ_OK) { + free(p); + return rc; + } + *out_buf = p; + *out_size = n; + return MZ_OK; +} + +const char *mz_strerror(int err) +{ + switch (err) { + case MZ_OK: return "ok"; + case MZ_ERR_IO: return "I/O error"; + case MZ_ERR_FORMAT: return "malformed archive"; + case MZ_ERR_TRUNCATED: return "archive truncated or offset out of bounds"; + case MZ_ERR_UNSUPPORTED: return "unsupported zip feature"; + case MZ_ERR_BUF: return "output buffer too small"; + case MZ_ERR_MEM: return "out of memory"; + case MZ_ERR_ARG: return "invalid argument"; + case MZ_ERR_CRC: return "CRC mismatch"; + default: return "unknown error"; + } +} diff --git a/tuku/logic/zip/zip.h b/tuku/logic/zip/zip.h new file mode 100644 index 0000000..845884e --- /dev/null +++ b/tuku/logic/zip/zip.h @@ -0,0 +1,86 @@ +/* zip.h — 纯 C zip 容器读取器(只依赖 libz 的 inflate/crc32 + stdio)。 + * + * 定位:tuku 漫画阅读器的 CBZ/zip 容器层——开包、列目录、按需解压单条目。 + * 不依赖 meow_ui.h / SDK / tuku 其它逻辑,宿主可裸测(见 tuku/test/test_zip.c)。 + * + * 用法: + * mz_archive *z = NULL; + * int rc = mz_open("book.cbz", &z); + * if (rc != MZ_OK) { fprintf(stderr, "%s\n", mz_strerror(rc)); return; } + * for (int i = 0; i < mz_count(z); i++) { + * const mz_entry *e = mz_entry_at(z, i); + * if (mz_entry_is_dir(e) || !is_image_name(e->name)) continue; + * size_t n = 0; + * void *px = NULL; + * if (mz_read_alloc(z, e, &px, &n) == MZ_OK) { decode(px, n); free(px); } + * } + * mz_close(z); + * + * 线程约束:单个 mz_archive 不是线程安全的(共享 FILE* 位置与条目表只读); + * 多线程请各自 mz_open 自己的 archive,或由调用方加锁。模块内无全局可变状态。 + * + * 内存:mz_open 只把 central directory 的条目元数据(条目表 + 名字副本)读进内存, + * 不展开条目数据。mz_read 解压到调用方缓冲(缓冲需 >= 该条目 uncomp_size); + * mz_read_alloc 只为单个条目分配一次。 + * + * 安全:所有偏移/长度都按文件实际大小校验;越界、截断、坏 magic、坏 CRC 一律 + * 返回错误码而不崩溃。条目名按原始字节透传(通常是 UTF-8,不做规范化/解码)。 + * 名字可能是 ".."、绝对路径或反斜杠——调用方若把它落到文件系统,必须自行拒绝 + * 越界路径。加密、zip64、分卷压缩包明确返回 MZ_ERR_UNSUPPORTED。 + * + * 错误码均为负值;MZ_OK 为 0。mz_strerror 对所有已知码返回静态字符串。 + */ +#ifndef TUKU_LOGIC_ZIP_ZIP_H +#define TUKU_LOGIC_ZIP_ZIP_H + +#include +#include + +enum { + MZ_OK = 0, + MZ_ERR_IO = -1, /* 文件读写失败(打开/seek/read) */ + MZ_ERR_FORMAT = -2, /* 结构非法:坏 magic、central directory 不一致、流损坏 */ + MZ_ERR_TRUNCATED = -3, /* 声明的偏移/长度越过文件尾(含局部头越界) */ + MZ_ERR_UNSUPPORTED = -4, /* 加密 / zip64 / 分卷 / 非 stored-deflate 压缩方式 */ + MZ_ERR_BUF = -5, /* 调用方缓冲小于条目 uncomp_size */ + MZ_ERR_MEM = -6, /* 内存分配失败 */ + MZ_ERR_ARG = -7, /* 空指针 / 越界 index 等调用错误 */ + MZ_ERR_CRC = -8 /* 解压结果 CRC-32 与 central directory 不符 */ +}; + +typedef struct mz_entry { + const char *name; /* NUL 结尾;内容为 zip 内原始字节,生命周期同 archive */ + uint32_t name_len; /* 原始名字字节数(不含结尾 NUL,name 中可能有内嵌 NUL) */ + uint16_t method; /* 0=stored, 8=deflate;其它值在 mz_read 时返回 UNSUPPORTED */ + uint16_t flags; /* zip general purpose flags(bit0=加密,bit11=UTF-8 名) */ + uint32_t crc; /* 条目内容 CRC-32 */ + uint64_t comp_size; /* 压缩后字节数(archive 内) */ + uint64_t uncomp_size; /* 原始字节数 */ + uint64_t local_offset; /* local file header 在 archive 内的偏移 */ +} mz_entry; + +typedef struct mz_archive mz_archive; + +/* 打开 zip。成功时 *out 为新 archive(mz_close 释放);失败时 *out == NULL。 */ +int mz_open(const char *path, mz_archive **out); +void mz_close(mz_archive *z); + +int mz_count(const mz_archive *z); +const mz_entry *mz_entry_at(const mz_archive *z, int index); /* 越界返回 NULL */ +const mz_entry *mz_find(const mz_archive *z, const char *name); /* 精确 strcmp,找不到 NULL */ + +/* 目录条目(名字以 '/' 结尾)。注意 zip 常见但没有强制要求目录条目存在。 */ +int mz_entry_is_dir(const mz_entry *e); + +/* 解压单条目到 buf。buf_size 必须 >= e->uncomp_size,否则 MZ_ERR_BUF。 + * 成功时 *out_size = uncomp_size(可为 0)。out_size 可为 NULL。 + * 校验 CRC 与声明尺寸;失败时 buf 内容不可用。 */ +int mz_read(mz_archive *z, const mz_entry *e, void *buf, size_t buf_size, size_t *out_size); + +/* mz_read 的便捷版:malloc 恰好 uncomp_size 字节(0 长度条目分配 1 字节)。 + * 成功时 *out_buf 由调用方 free。 */ +int mz_read_alloc(mz_archive *z, const mz_entry *e, void **out_buf, size_t *out_size); + +const char *mz_strerror(int err); + +#endif /* TUKU_LOGIC_ZIP_ZIP_H */ diff --git a/tuku/test/make_zip_samples.py b/tuku/test/make_zip_samples.py new file mode 100644 index 0000000..ccf1885 --- /dev/null +++ b/tuku/test/make_zip_samples.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""生成 tuku zip 读取器的宿主测试样例(真实 zipfile + 定点字节修补)。 + +用法: python3 make_zip_samples.py + +生成: + basic.zip 5 条目:stored 文本 / deflate 大二进制 / 中文名 deflate / + 目录项 / 0 字节 deflate + comment.zip 单条目 + 归档注释(EOCD 注释长度路径) + empty.zip 0 条目 + truncated.zip basic.zip 砍半(EOCD 丢失) + badmagic.zip 非 zip 字节 + badoffset.zip 首条目 local header 偏移改成 0xFFFFFF00(越界) + badcrc.zip 首条目 CD 里 CRC 翻转(内容解出但校验失败) + method99.zip 首条目压缩方式改成 99,CD 与局部头都改 + badlocal.zip 首条目局部头签名改成 "BAD!" + bigsize.zip deflate 条目 CD comp_size 改成 8 MiB(数据越界) + zip64claim.zip EOCD 总条目数改成 0xFFFF(zip64 标记) + bigrand.zip 单条目 256 KiB 不可压 deflate(压缩流 > 64 KiB,覆盖分块 inflate) +""" +import os +import struct +import sys +import zipfile + +PAGE2 = (b"MEOW" * 10 + bytes(range(256))) * 180 # 53280 字节,可压缩 +HELLO = b"hello zip\n" * 3 +CJK_NAME = "sub/dir/\u4e2d\u6587\u540d.txt" +CJK_DATA = "\u4e2d\u6587\u5185\u5bb9\n".encode("utf-8") +BIGRAND_LEN = 262144 + + +def lcg_bytes(n): + """与 test_zip.c 的 build_rand 同一个 LCG,用于 256 KiB 不可压样例。""" + x = 123456789 + out = bytearray(n) + for i in range(n): + x = (1103515245 * x + 12345) & 0x7FFFFFFF + out[i] = (x >> 16) & 0xFF + return bytes(out) + +EOCD_SIG = b"PK\x05\x06" +CD_SIG = b"PK\x01\x02" + + +def write_basic(path): + with zipfile.ZipFile(path, "w") as z: + z.writestr(zipfile.ZipInfo("hello.txt", (2024, 1, 1, 0, 0, 0)), + HELLO, compress_type=zipfile.ZIP_STORED) + z.writestr(zipfile.ZipInfo("page2.png", (2024, 1, 1, 0, 0, 0)), + PAGE2, compress_type=zipfile.ZIP_DEFLATED) + z.writestr(zipfile.ZipInfo(CJK_NAME, (2024, 1, 1, 0, 0, 0)), + CJK_DATA, compress_type=zipfile.ZIP_DEFLATED) + z.writestr(zipfile.ZipInfo("onlydir/", (2024, 1, 1, 0, 0, 0)), + b"", compress_type=zipfile.ZIP_STORED) + z.writestr(zipfile.ZipInfo("empty.bin", (2024, 1, 1, 0, 0, 0)), + b"", compress_type=zipfile.ZIP_DEFLATED) + + +def write_comment(path): + with zipfile.ZipFile(path, "w") as z: + z.writestr("note.txt", b"commented\n", compress_type=zipfile.ZIP_DEFLATED) + z.comment = b"meow archive comment" + + +def load(path): + with open(path, "rb") as f: + return bytearray(f.read()) + + +def save(path, data): + with open(path, "wb") as f: + f.write(bytes(data)) + + +def eocd_info(data): + i = data.rfind(EOCD_SIG) + if i < 0: + raise SystemExit("generator: no EOCD in fixture") + total, cd_size, cd_off = struct.unpack_from("") + out = sys.argv[1] + os.makedirs(out, exist_ok=True) + + basic = os.path.join(out, "basic.zip") + write_basic(basic) + write_comment(os.path.join(out, "comment.zip")) + with zipfile.ZipFile(os.path.join(out, "empty.zip"), "w"): + pass + + raw = load(basic) + save(os.path.join(out, "truncated.zip"), raw[:len(raw) // 2]) + save(os.path.join(out, "badmagic.zip"), b"NOTAZIP!" * 100) + + d = load(basic) + hello = find_record(d, "hello.txt") + struct.pack_into(" +#include +#include +#include +#include + +#define PATH_CAP 1024 +#define PAGE2_LEN 53280 +#define PAGE2_UNIT 296 +#define PAGE2_REPS 180 +#define BIGRAND_LEN 262144 +#define CJK_NAME "sub/dir/\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.txt" +#define CJK_DATA "\xe4\xb8\xad\xe6\x96\x87\xe5\x86\x85\xe5\xae\xb9\n" +#define HELLO_DATA "hello zip\nhello zip\nhello zip\n" + +static int g_pass; +static int g_fail; +static char g_dir[PATH_CAP]; +static const char *g_argv0; + +#define CHECK(cond, ...) do { \ + if (cond) { \ + g_pass++; \ + } else { \ + g_fail++; \ + printf("FAIL %s:%d ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ +} while (0) + +static const char *fixtures[] = { + "basic.zip", "comment.zip", "empty.zip", "truncated.zip", "badmagic.zip", + "badoffset.zip", "badcrc.zip", "method99.zip", "badlocal.zip", + "bigsize.zip", "zip64claim.zip", "bigrand.zip", "fuzz.zip", +}; + +static void join_path(char *out, size_t cap, const char *name) +{ + snprintf(out, cap, "%s/%s", g_dir, name); +} + +static uint8_t *slurp_file(const char *path, size_t *len) +{ + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + return NULL; + } + long end = ftell(f); + if (end < 0 || fseek(f, 0, SEEK_SET) != 0) { + fclose(f); + return NULL; + } + uint8_t *buf = malloc((size_t)end ? (size_t)end : 1); + if (!buf) { + fclose(f); + return NULL; + } + size_t got = fread(buf, 1, (size_t)end, f); + fclose(f); + if (got != (size_t)end) { + free(buf); + return NULL; + } + *len = got; + return buf; +} + +static int dump_file(const char *path, const uint8_t *buf, size_t len) +{ + FILE *f = fopen(path, "wb"); + if (!f) return 0; + size_t put = fwrite(buf, 1, len, f); + return fclose(f) == 0 && put == len; +} + +static uint32_t fuzz_next(uint32_t *state) +{ + *state = *state * 1103515245u + 12345u; + return *state; +} + +static const char *find_generator(char *buf, size_t cap) +{ + static const char *fallbacks[] = { + "test/make_zip_samples.py", "make_zip_samples.py", + }; + const char *a0 = g_argv0 ? g_argv0 : ""; + const char *slash = strrchr(a0, '/'); + + if (a0[0] && strcmp(a0, "-") != 0 && slash) { + snprintf(buf, cap, "%.*s/make_zip_samples.py", (int)(slash - a0), a0); + if (access(buf, R_OK) == 0) return buf; + } + for (size_t i = 0; i < sizeof fallbacks / sizeof fallbacks[0]; i++) { + if (access(fallbacks[i], R_OK) == 0) return fallbacks[i]; + } + return fallbacks[0]; +} + +static int run_generator(const char *gen) +{ + char cmd[PATH_CAP * 2 + 32]; + snprintf(cmd, sizeof cmd, "python3 '%s' '%s'", gen, g_dir); + int st = system(cmd); + if (st == -1) return 0; + return WIFEXITED(st) && WEXITSTATUS(st) == 0; +} + +static mz_archive *open_ok(const char *name, int line) +{ + char path[PATH_CAP]; + join_path(path, sizeof path, name); + mz_archive *z = NULL; + int rc = mz_open(path, &z); + if (rc != MZ_OK || !z) { + g_fail++; + printf("FAIL %s:%d open %s rc=%d (%s)\n", __FILE__, line, name, rc, + mz_strerror(rc)); + if (z) mz_close(z); + return NULL; + } + g_pass++; + return z; +} + +static void expect_open_err(const char *name, int want, int line) +{ + char path[PATH_CAP]; + join_path(path, sizeof path, name); + mz_archive *z = NULL; + int rc = mz_open(path, &z); + CHECK(rc == want, "open %s: want %d (%s), got %d (%s)", name, want, + mz_strerror(want), rc, mz_strerror(rc)); + CHECK(z == NULL, "open %s failed but *out != NULL", name); + if (z) mz_close(z); + (void)line; +} + +static void build_page2(uint8_t *buf) +{ + uint8_t *w = buf; + for (int r = 0; r < PAGE2_REPS; r++) { + for (int i = 0; i < 10; i++) { + memcpy(w, "MEOW", 4); + w += 4; + } + for (int b = 0; b < 256; b++) *w++ = (uint8_t)b; + } +} + +static void build_rand(uint8_t *buf) +{ + uint32_t x = 123456789u; + for (int i = 0; i < BIGRAND_LEN; i++) { + x = (1103515245u * x + 12345u) & 0x7FFFFFFFu; + buf[i] = (uint8_t)(x >> 16); + } +} + +static void test_basic(void) +{ + mz_archive *z = open_ok("basic.zip", __LINE__); + if (!z) return; + + CHECK(mz_count(z) == 5, "count=%d", mz_count(z)); + + const mz_entry *e0 = mz_entry_at(z, 0); + const mz_entry *e1 = mz_entry_at(z, 1); + const mz_entry *e2 = mz_entry_at(z, 2); + const mz_entry *e3 = mz_entry_at(z, 3); + const mz_entry *e4 = mz_entry_at(z, 4); + CHECK(e0 && e1 && e2 && e3 && e4, "entry_at returned NULL"); + if (!(e0 && e1 && e2 && e3 && e4)) { + mz_close(z); + return; + } + + CHECK(strcmp(e0->name, "hello.txt") == 0, "e0 name=%s", e0->name); + CHECK(e0->method == 0, "hello method=%u", e0->method); + CHECK(e0->comp_size == 30 && e0->uncomp_size == 30, + "hello sizes comp=%llu uncomp=%llu", + (unsigned long long)e0->comp_size, + (unsigned long long)e0->uncomp_size); + CHECK(mz_entry_is_dir(e0) == 0, "hello treated as dir"); + + CHECK(strcmp(e1->name, "page2.png") == 0, "e1 name=%s", e1->name); + CHECK(e1->method == 8, "page2 method=%u", e1->method); + CHECK(e1->uncomp_size == PAGE2_LEN, "page2 uncomp=%llu", + (unsigned long long)e1->uncomp_size); + CHECK(e1->comp_size < e1->uncomp_size, "page2 not compressed: %llu/%llu", + (unsigned long long)e1->comp_size, + (unsigned long long)e1->uncomp_size); + + CHECK(strcmp(e2->name, CJK_NAME) == 0, "cjk name bytes differ: %s", e2->name); + CHECK(e2->name_len == strlen(CJK_NAME), "cjk name_len=%u", e2->name_len); + CHECK(e2->method == 8, "cjk method=%u", e2->method); + CHECK((e2->flags & 0x0800u) != 0, "cjk UTF-8 flag missing: flags=0x%x", + e2->flags); + CHECK(e2->uncomp_size == strlen(CJK_DATA), "cjk uncomp=%llu", + (unsigned long long)e2->uncomp_size); + + CHECK(strcmp(e3->name, "onlydir/") == 0, "e3 name=%s", e3->name); + CHECK(mz_entry_is_dir(e3) == 1, "onlydir/ not dir"); + CHECK(e3->method == 0 && e3->uncomp_size == 0 && e3->comp_size == 0, + "onlydir sizes/method wrong"); + + CHECK(strcmp(e4->name, "empty.bin") == 0, "e4 name=%s", e4->name); + CHECK(e4->method == 8 && e4->uncomp_size == 0, + "empty.bin method=%u uncomp=%llu", e4->method, + (unsigned long long)e4->uncomp_size); + + CHECK(mz_find(z, "page2.png") == e1, "mz_find page2 mismatch"); + CHECK(mz_find(z, "onlydir/") == e3, "mz_find onlydir mismatch"); + CHECK(mz_find(z, "missing.txt") == NULL, "mz_find missing != NULL"); + CHECK(mz_entry_at(z, -1) == NULL, "entry_at(-1) != NULL"); + CHECK(mz_entry_at(z, 5) == NULL, "entry_at(count) != NULL"); + + uint8_t buf[64]; + size_t n = 999; + CHECK(mz_read(z, e0, buf, sizeof buf, &n) == MZ_OK, "read hello rc"); + CHECK(n == 30, "read hello n=%zu", n); + CHECK(n == 30 && memcmp(buf, HELLO_DATA, 30) == 0, "hello content"); + + uint8_t *page = malloc(PAGE2_LEN); + uint8_t *expect = malloc(PAGE2_LEN); + CHECK(page && expect, "malloc page2"); + if (page && expect) { + build_page2(expect); + n = 0; + CHECK(mz_read(z, e1, page, PAGE2_LEN, &n) == MZ_OK, "read page2 rc"); + CHECK(n == PAGE2_LEN, "read page2 n=%zu", n); + CHECK(n == PAGE2_LEN && memcmp(page, expect, PAGE2_LEN) == 0, + "page2 content"); + } + free(page); + free(expect); + + n = 999; + CHECK(mz_read(z, e2, buf, 4, &n) == MZ_ERR_BUF, "cjk small buf rc"); + CHECK(n == 0, "buf error left out_size=%zu", n); + + uint8_t cjk[64]; + n = 0; + CHECK(mz_read(z, e2, cjk, sizeof cjk, &n) == MZ_OK, "read cjk rc"); + CHECK(n == strlen(CJK_DATA) && + memcmp(cjk, CJK_DATA, strlen(CJK_DATA)) == 0, "cjk content"); + + uint8_t dummy = 0xAA; + n = 999; + CHECK(mz_read(z, e3, &dummy, sizeof dummy, &n) == MZ_OK, "read dir entry"); + CHECK(n == 0 && dummy == 0xAA, "dir entry produced data"); + n = 999; + CHECK(mz_read(z, e4, &dummy, sizeof dummy, &n) == MZ_OK, "read empty deflate"); + CHECK(n == 0, "empty deflate n=%zu", n); + + void *alloc_buf = NULL; + n = 0; + CHECK(mz_read_alloc(z, e0, &alloc_buf, &n) == MZ_OK, "read_alloc rc"); + CHECK(alloc_buf && n == 30 && memcmp(alloc_buf, HELLO_DATA, 30) == 0, + "read_alloc content"); + free(alloc_buf); + + n = 0; + CHECK(mz_read(z, e0, NULL, 10, &n) == MZ_ERR_ARG, "NULL buf rc"); + CHECK(mz_read(NULL, e0, buf, sizeof buf, &n) == MZ_ERR_ARG, "NULL archive rc"); + CHECK(mz_read(z, NULL, buf, sizeof buf, &n) == MZ_ERR_ARG, "NULL entry rc"); + CHECK(mz_read_alloc(z, e0, NULL, &n) == MZ_ERR_ARG, "read_alloc NULL out"); + + mz_close(z); +} + +static void test_comment(void) +{ + mz_archive *z = open_ok("comment.zip", __LINE__); + if (!z) return; + + CHECK(mz_count(z) == 1, "comment count=%d", mz_count(z)); + const mz_entry *e = mz_find(z, "note.txt"); + CHECK(e != NULL, "comment entry missing"); + if (e) { + uint8_t buf[32]; + size_t n = 0; + CHECK(mz_read(z, e, buf, sizeof buf, &n) == MZ_OK, "comment read rc"); + CHECK(n == 10 && memcmp(buf, "commented\n", 10) == 0, + "comment content n=%zu", n); + } + mz_close(z); +} + +static void test_empty(void) +{ + mz_archive *z = open_ok("empty.zip", __LINE__); + if (!z) return; + + CHECK(mz_count(z) == 0, "empty count=%d", mz_count(z)); + CHECK(mz_entry_at(z, 0) == NULL, "empty entry_at(0) != NULL"); + CHECK(mz_find(z, "x") == NULL, "empty find != NULL"); + mz_close(z); +} + +static void test_bad_archives(void) +{ + expect_open_err("truncated.zip", MZ_ERR_FORMAT, __LINE__); + expect_open_err("badmagic.zip", MZ_ERR_FORMAT, __LINE__); + expect_open_err("badoffset.zip", MZ_ERR_TRUNCATED, __LINE__); + expect_open_err("zip64claim.zip", MZ_ERR_UNSUPPORTED, __LINE__); + + mz_archive *z = open_ok("badcrc.zip", __LINE__); + if (z) { + uint8_t buf[64]; + size_t n = 0; + CHECK(mz_read(z, mz_find(z, "hello.txt"), buf, sizeof buf, &n) == + MZ_ERR_CRC, "badcrc rc"); + mz_close(z); + } + + z = open_ok("method99.zip", __LINE__); + if (z) { + uint8_t buf[64]; + size_t n = 0; + CHECK(mz_read(z, mz_find(z, "hello.txt"), buf, sizeof buf, &n) == + MZ_ERR_UNSUPPORTED, "method99 rc"); + mz_close(z); + } + + z = open_ok("badlocal.zip", __LINE__); + if (z) { + uint8_t buf[64]; + size_t n = 0; + CHECK(mz_read(z, mz_find(z, "hello.txt"), buf, sizeof buf, &n) == + MZ_ERR_FORMAT, "badlocal rc"); + mz_close(z); + } + + z = open_ok("bigsize.zip", __LINE__); + if (z) { + uint8_t *buf = malloc(PAGE2_LEN); + size_t n = 0; + CHECK(buf != NULL, "malloc bigsize buf"); + if (buf) { + CHECK(mz_read(z, mz_find(z, "page2.png"), buf, PAGE2_LEN, &n) == + MZ_ERR_TRUNCATED, "bigsize rc"); + free(buf); + } + mz_close(z); + } +} + +static void test_bigrand(void) +{ + mz_archive *z = open_ok("bigrand.zip", __LINE__); + if (!z) return; + + CHECK(mz_count(z) == 1, "bigrand count=%d", mz_count(z)); + const mz_entry *e = mz_find(z, "rand.bin"); + CHECK(e != NULL, "bigrand entry missing"); + if (!e) { + mz_close(z); + return; + } + CHECK(e->uncomp_size == BIGRAND_LEN, "bigrand uncomp=%llu", + (unsigned long long)e->uncomp_size); + CHECK(e->comp_size > 65536, "bigrand comp=%llu not multi-chunk", + (unsigned long long)e->comp_size); + + uint8_t *buf = malloc(BIGRAND_LEN); + uint8_t *expect = malloc(BIGRAND_LEN); + CHECK(buf && expect, "malloc bigrand"); + if (buf && expect) { + build_rand(expect); + size_t n = 0; + CHECK(mz_read(z, e, buf, BIGRAND_LEN, &n) == MZ_OK, "bigrand read rc"); + CHECK(n == BIGRAND_LEN, "bigrand n=%zu", n); + CHECK(n == BIGRAND_LEN && memcmp(buf, expect, BIGRAND_LEN) == 0, + "bigrand content"); + } + free(buf); + free(expect); + mz_close(z); +} + +/* 对 basic.zip 做确定性字节翻转/截断,断言开包与解压全程不崩且错误码可用。 */ +static void test_fuzz(void) +{ + char zip_path[PATH_CAP]; + char fuzz_path[PATH_CAP]; + join_path(zip_path, sizeof zip_path, "basic.zip"); + join_path(fuzz_path, sizeof fuzz_path, "fuzz.zip"); + + size_t base_len = 0; + uint8_t *base = slurp_file(zip_path, &base_len); + CHECK(base != NULL, "fuzz slurp basic.zip"); + if (!base) return; + + uint8_t *mut = malloc(base_len); + CHECK(mut != NULL, "fuzz malloc"); + if (!mut) { + free(base); + return; + } + + uint32_t seed = 0xC0FFEEu; + int opened = 0; + int rejected = 0; + int bad_rc = 0; + + for (int it = 0; it < 100; it++) { + size_t len = base_len; + memcpy(mut, base, base_len); + + int flips = 1 + (int)(fuzz_next(&seed) % 8); + for (int k = 0; k < flips; k++) { + size_t pos = fuzz_next(&seed) % base_len; + mut[pos] ^= (uint8_t)(fuzz_next(&seed) & 0xFF); + } + if (it % 3 == 2) len = fuzz_next(&seed) % (base_len + 1); + + if (!dump_file(fuzz_path, mut, len)) { + CHECK(0, "fuzz dump iteration %d", it); + continue; + } + + mz_archive *z = NULL; + int rc = mz_open(fuzz_path, &z); + if (rc == MZ_OK && z) { + opened++; + for (int i = 0; i < mz_count(z); i++) { + const mz_entry *e = mz_entry_at(z, i); + if (!e || e->uncomp_size > (1u << 20)) continue; + size_t cap = (size_t)e->uncomp_size; + uint8_t *b = malloc(cap ? cap : 1); + if (!b) continue; + size_t n = 0; + int rrc = mz_read(z, e, b, cap, &n); + if (rrc > MZ_OK || rrc < MZ_ERR_CRC) bad_rc++; + if (rrc == MZ_OK && n != (size_t)e->uncomp_size) bad_rc++; + free(b); + } + mz_close(z); + } else { + rejected++; + if (z) bad_rc++; + } + } + + free(mut); + free(base); + unlink(fuzz_path); + CHECK(rejected > 0, "fuzz: all 100 corrupt archives opened"); + CHECK(opened > 0, "fuzz: no corrupted archive reached read path"); + CHECK(bad_rc == 0, "fuzz: %d out-of-contract results", bad_rc); +} + +static void test_api(void) +{ + char path[PATH_CAP]; + mz_archive *z = (mz_archive *)&g_dir; + + CHECK(mz_open(NULL, &z) == MZ_ERR_ARG, "open(NULL path) rc"); + CHECK(z == NULL, "open(NULL path) left *out set"); + CHECK(mz_open("x", NULL) == MZ_ERR_ARG, "open(NULL out) rc"); + + join_path(path, sizeof path, "no_such_file.zip"); + z = (mz_archive *)&g_dir; + CHECK(mz_open(path, &z) == MZ_ERR_IO, "open(missing) rc"); + CHECK(z == NULL, "open(missing) left *out set"); + + CHECK(mz_count(NULL) == 0, "count(NULL)"); + CHECK(mz_entry_at(NULL, 0) == NULL, "entry_at(NULL)"); + CHECK(mz_find(NULL, "x") == NULL, "find(NULL)"); + CHECK(mz_entry_is_dir(NULL) == 0, "is_dir(NULL)"); + mz_close(NULL); + + const int codes[] = {MZ_OK, MZ_ERR_IO, MZ_ERR_FORMAT, MZ_ERR_TRUNCATED, + MZ_ERR_UNSUPPORTED, MZ_ERR_BUF, MZ_ERR_MEM, + MZ_ERR_ARG, MZ_ERR_CRC, 12345}; + for (size_t i = 0; i < sizeof codes / sizeof codes[0]; i++) { + const char *s = mz_strerror(codes[i]); + CHECK(s && s[0] != '\0', "strerror(%d) empty", codes[i]); + } +} + +static void cleanup(void) +{ + char path[PATH_CAP]; + for (size_t i = 0; i < sizeof fixtures / sizeof fixtures[0]; i++) { + join_path(path, sizeof path, fixtures[i]); + unlink(path); + } + rmdir(g_dir); +} + +static int make_temp_dir(void) +{ + const char *bases[3]; + int nb = 0; + const char *env = getenv("TMPDIR"); + if (env && env[0]) bases[nb++] = env; + bases[nb++] = "/data/data/com.termux/files/usr/tmp"; + bases[nb++] = "/tmp"; + + for (int i = 0; i < nb; i++) { + char tmpl[PATH_CAP]; + snprintf(tmpl, sizeof tmpl, "%s/tuku_zip_XXXXXX", bases[i]); + if (mkdtemp(tmpl)) { + snprintf(g_dir, sizeof g_dir, "%s", tmpl); + return 1; + } + } + return 0; +} + +int main(int argc, char **argv) +{ + char gen_buf[PATH_CAP]; + const char *gen; + + g_argv0 = argv[0]; + gen = argc > 1 ? argv[1] : find_generator(gen_buf, sizeof gen_buf); + + if (!make_temp_dir()) { + perror("mkdtemp"); + return 2; + } + + if (!run_generator(gen)) { + printf("generator failed: %s\n", gen); + cleanup(); + return 2; + } + + test_basic(); + test_comment(); + test_empty(); + test_bad_archives(); + test_bigrand(); + test_fuzz(); + test_api(); + + cleanup(); + + printf("zip tests: %d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} From 562cc449312eee86af14fc3e90eabba28752ca42 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 11:58:12 +0800 Subject: [PATCH 10/38] =?UTF-8?q?feat(tuku):=20CBZ=20=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E5=86=8C=E9=9B=86=E6=88=90=E2=80=94=E2=80=94=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E9=A1=B5=E6=BA=90/=E4=B9=A6=E5=BA=93=E5=88=97=E5=86=8C/?= =?UTF-8?q?=E8=A7=A3=E5=8E=8B=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/reader/archive_source.c:档案册页源实现 M6 mr_book 契约 (mz_open/mz_count/mz_entry_at/mz_read_alloc),条目自然序 + 图片后缀 + 路径安全(`..`/绝对路径/反斜杠/隐藏段/内嵌 NUL)+ 单页尺寸上限过滤; meta 按需解压只读图片头(不落缓存,结果按页记忆);mr_book_open_auto 统一目录册/档案册开册,翻页与条漫两套消费代码零改动。 - logic/reader/page_cache.c/h:解压页落 MeowData//cache,LRU 64 页或 32MiB,启动清 mrpage_* 孤儿,写入 .part + rename;与缩略图缓存同目录、 前缀隔离;互斥量保护,解码/缩略图线程可并发。 - logic/library.c/h:扫描直接子文件里的 .cbz/.zip 成册(行=文件名+页数, 点入直接阅读);档案探测由调用方注入(logic 不依赖 zip 容器),不注入时 与 M7 行为逐字一致;册间衔接兄弟表=含图目录∪含页档案册,同列自然序。 条目构造/副标题/角标/兄弟判据拆成小函数,便于 M13 书库重写拼接。 - ui/library:档案册行 + 封面缩略图(解压档内首图)、扫描注入探测、缓存 目录统一由 page_cache 推导(一份规则)。 - ui/reader:open_book 走 mr_book_open_auto;跨册衔接纳入档案册。 - build_tuku.sh 补 zip/archive/cache 源与 sysroot zlib 头(zip.c include );run_tests.sh 纳入 zip+cbz 用例;run_webtoon_tests.sh 的宿主 集成构建补新源与 -lz;.gitignore 同步测试产物。 - test/test_cbz.c(267 项断言)+ make_cbz_samples.py 真实 cbz 夹具: 列档/自然序/解压字节 CRC/路径安全/坏档与 zip 炸弹容错/LRU 与 32MiB 上限/ 启动清孤儿/续读角标/封面首图。 --- .gitignore | 6 + tuku/build_tuku.sh | 7 +- tuku/logic/library.c | 189 +++++-- tuku/logic/library.h | 53 +- tuku/logic/reader/archive_source.c | 395 ++++++++++++++ tuku/logic/reader/page_cache.c | 350 ++++++++++++ tuku/logic/reader/page_cache.h | 68 +++ tuku/logic/reader/page_source.c | 97 +++- tuku/logic/reader/page_source.h | 78 +++ tuku/test/make_cbz_samples.py | 229 ++++++++ tuku/test/run_cbz_tests.sh | 13 + tuku/test/run_tests.sh | 4 + tuku/test/run_webtoon_tests.sh | 6 +- tuku/test/test_cbz.c | 847 +++++++++++++++++++++++++++++ tuku/ui/library/library.c | 60 +- tuku/ui/reader/reader.c | 36 +- 16 files changed, 2330 insertions(+), 108 deletions(-) create mode 100644 tuku/logic/reader/archive_source.c create mode 100644 tuku/logic/reader/page_cache.c create mode 100644 tuku/logic/reader/page_cache.h create mode 100755 tuku/test/make_cbz_samples.py create mode 100755 tuku/test/run_cbz_tests.sh create mode 100644 tuku/test/test_cbz.c diff --git a/.gitignore b/.gitignore index 8e90778..9e03f12 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,12 @@ tuku/picbrowser tuku/*.mpk tuku/build/ tuku/test/test_library +tuku/test/test_reader_core +tuku/test/test_settings_reader_persist +tuku/test/test_settings_lock_host +tuku/test/test_pin +tuku/test/test_zip +tuku/test/test_cbz # ---- 杂项 ---- *.orig diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index 602ceec..48752ac 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -4,15 +4,18 @@ # SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖; # 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/os.meow.picbrowser。 # 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。 +# -I"$SYSROOT/include":zip 容器层(logic/zip/zip.c)直接 include , +# 头文件随 sysroot 的 libz.a 一起提供(SDK 自身只用最小 zlib ABI 自声明)。 set -e cd "$(dirname "$0")" CC="${MEOW_CC:-$HOME/.tools/zig-mipsel-cc}" SYSROOT="${MEOW_SYSROOT:-$HOME/.tools/mipsel-linux-musleabi-sysroot}" SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" -"$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ +"$CC" -Os -static -Wall -Wextra -I"$SDK" -I"$SYSROOT/include" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ - logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c logic/pin.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c logic/pin.c \ "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser ls -la picbrowser file picbrowser diff --git a/tuku/logic/library.c b/tuku/logic/library.c index b640401..4db9545 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -12,6 +12,14 @@ * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, * 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 + * + * M11 追加(档案册,仍然不改目录扫描的主体语义): + * - 目标目录的**直接子文件**里 .cbz/.zip 也成行(行=文件名+档内页数,has_images=1); + * - 档案册探测由调用方注入(mr_lib_archive_fn),logic 层不依赖 zip 容器; + * 不注入时所有 *_ex 入口与 M7 行为逐字一致; + * - 册间衔接的兄弟表从「含图目录」扩成「含图目录 ∪ 含图片页的档案册」,同列自然序。 + * 小函数边界(add_dir_entry / add_archive_entry / entry_subtitle / entry_badge / + * sibling_is_book)刻意留给 M13 的书库重写复用。 */ #include "library.h" #include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ @@ -66,6 +74,15 @@ int mr_path_is_image(const char *name) strcasecmp(dot, "jpeg") == 0 || strcasecmp(dot, "bmp") == 0; } +int mr_path_is_archive(const char *name) +{ + if (!name) return 0; + const char *dot = strrchr(name, '.'); + if (!dot || dot == name) return 0; + dot++; + return strcasecmp(dot, "cbz") == 0 || strcasecmp(dot, "zip") == 0; +} + int mr_natural_cmp(const char *a, const char *b) { const unsigned char *pa = (const unsigned char *)a; @@ -105,12 +122,22 @@ int mr_natural_cmp(const char *a, const char *b) typedef struct scan_info { int pages; /* 直接图片数 */ int subdirs; /* 含图子目录数 */ - int nav; /* 1=本目录最终含图(可直接看或可导航到图) */ + int archives; /* 直接档案册数(.cbz/.zip 且探测到图片页) */ + int nav; /* 1=本目录最终含书(可直接看或可导航到书) */ char cover[MR_PATH_MAX]; /* 缩略图源 */ char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ } scan_info; -static void scan_dir(const char *path, int depth, int max_depth, scan_info *si) +/* 档案册探测(UI 注入;NULL=纯目录书库,行为与 M7 一致)。 */ +static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) +{ + if (!probe || !path || !path[0]) return 0; + int n = probe(path, ud); + return n > 0 ? n : 0; +} + +static void scan_dir(const char *path, int depth, int max_depth, scan_info *si, + mr_lib_archive_fn probe, void *ud) { memset(si, 0, sizeof *si); DIR *d = opendir(path); @@ -132,21 +159,25 @@ static void scan_dir(const char *path, int depth, int max_depth, scan_info *si) if (S_ISDIR(st.st_mode)) { if (depth >= max_depth) continue; scan_info child; - scan_dir(full, depth + 1, max_depth, &child); + scan_dir(full, depth + 1, max_depth, &child, probe, ud); if (!child.nav) continue; si->subdirs++; if (!si->cover[0] && child.cover[0]) copy_str(si->cover, (int)sizeof si->cover, child.cover); - } else if (S_ISREG(st.st_mode) && mr_path_is_image(name)) { - si->pages++; - if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { - copy_str(si->first_name, (int)sizeof si->first_name, name); - copy_str(si->cover, (int)sizeof si->cover, full); + } else if (S_ISREG(st.st_mode)) { + if (mr_path_is_image(name)) { + si->pages++; + if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { + copy_str(si->first_name, (int)sizeof si->first_name, name); + copy_str(si->cover, (int)sizeof si->cover, full); + } + } else if (mr_path_is_archive(name)) { + if (archive_pages_at(probe, ud, full) > 0) si->archives++; } } } closedir(d); - si->nav = (si->pages > 0 || si->subdirs > 0); + si->nav = (si->pages > 0 || si->subdirs > 0 || si->archives > 0); } void mr_library_free(mr_library *lib) @@ -179,7 +210,69 @@ static int entry_cmp(const void *pa, const void *pb) return mr_natural_cmp(a->name, b->name); } -int mr_library_scan(mr_library *lib, const char *dir, int max_depth) +/* 副标题(M11 扩展):直接含图 → pages;只有档案册 → books;否则 folders。 */ +static void entry_subtitle(mr_library_entry *e, int archive_books) +{ + if (e->has_images) + snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, + e->pages == 1 ? "" : "s"); + else if (archive_books > 0) + snprintf(e->subtitle, sizeof e->subtitle, "%d book%s", archive_books, + archive_books == 1 ? "" : "s"); + else + snprintf(e->subtitle, sizeof e->subtitle, "%d folder%s", e->subdirs, + e->subdirs == 1 ? "" : "s"); +} + +/* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 + * 档案册以档案路径为册标识,同一套逻辑复用。 + * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ +static void entry_badge(mr_library_entry *e) +{ + if (!e->has_images) return; + char badge[24]; + if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; + int used = (int)strlen(e->subtitle); + if (used < (int)sizeof e->subtitle - 2) + snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); +} + +/* 文件夹册(含可导航中间目录)一行。 */ +static int add_dir_entry(mr_library *lib, const char *name, const char *full, + const scan_info *si) +{ + mr_library_entry e; + memset(&e, 0, sizeof e); + if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; + if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; + copy_str(e.cover, (int)sizeof e.cover, si->cover); + e.pages = si->pages; + e.subdirs = si->subdirs; + e.has_images = (si->pages > 0); + entry_subtitle(&e, si->archives); + entry_badge(&e); + return lib_add(lib, &e); +} + +/* 档案册(.cbz/.zip)一行:文件即一本书,点入直接阅读。 + * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ +static int add_archive_entry(mr_library *lib, const char *name, const char *full, + int pages) +{ + mr_library_entry e; + memset(&e, 0, sizeof e); + if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; + if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; + e.pages = pages; + e.subdirs = 0; + e.has_images = 1; + snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", pages, pages == 1 ? "" : "s"); + entry_badge(&e); + return lib_add(lib, &e); +} + +int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, + mr_lib_archive_fn archive_pages, void *ud) { if (!lib) return -1; mr_library_free(lib); @@ -201,38 +294,22 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) struct stat st; if (lstat(full, &st) != 0) continue; - if (S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; + if (S_ISLNK(st.st_mode)) continue; - scan_info si; - scan_dir(full, 1, max_depth, &si); - if (!si.nav) continue; - - mr_library_entry e; - memset(&e, 0, sizeof e); - if (copy_str(e.name, (int)sizeof e.name, name) != 0) continue; - if (copy_str(e.path, (int)sizeof e.path, full) != 0) continue; - copy_str(e.cover, (int)sizeof e.cover, si.cover); - e.pages = si.pages; - e.subdirs = si.subdirs; - e.has_images = (si.pages > 0); - if (e.has_images) - snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", e.pages, - e.pages == 1 ? "" : "s"); - else - snprintf(e.subtitle, sizeof e.subtitle, "%d folder%s", e.subdirs, - e.subdirs == 1 ? "" : "s"); - /* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 - * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ - if (e.has_images) { - char badge[24]; - if (mr_cont_badge(e.path, badge, (int)sizeof badge)) { - int used = (int)strlen(e.subtitle); - if (used < (int)sizeof e.subtitle - 2) - snprintf(e.subtitle + used, sizeof e.subtitle - (size_t)used, - " · %s", badge); - } + int rc = 0; + if (S_ISDIR(st.st_mode)) { + scan_info si; + scan_dir(full, 1, max_depth, &si, archive_pages, ud); + if (!si.nav) continue; + rc = add_dir_entry(lib, name, full, &si); + } else if (S_ISREG(st.st_mode) && mr_path_is_archive(name)) { + int pages = archive_pages_at(archive_pages, ud, full); + if (pages <= 0) continue; /* 坏档/无图片页:不当书 */ + rc = add_archive_entry(lib, name, full, pages); + } else { + continue; } - if (lib_add(lib, &e) != 0) { + if (rc != 0) { closedir(d); mr_library_free(lib); return -1; @@ -244,6 +321,11 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) return lib->count; } +int mr_library_scan(mr_library *lib, const char *dir, int max_depth) +{ + return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); +} + int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; @@ -295,7 +377,23 @@ static int name_ptr_cmp(const void *pa, const void *pb) return mr_natural_cmp(a, b); } -int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) +/* 兄弟册判据(M11):目录=直接含图;普通文件=档案册且有图片页。 + * 两者同列,交给 name_ptr_cmp 自然序混排。 */ +static int sibling_is_book(const char *full, const struct stat *st, + mr_lib_archive_fn probe, void *ud) +{ + if (S_ISDIR(st->st_mode)) return mr_library_dir_has_images(full); + if (S_ISREG(st->st_mode)) { + const char *slash = strrchr(full, '/'); + const char *name = slash ? slash + 1 : full; + if (!mr_path_is_archive(name)) return 0; + return archive_pages_at(probe, ud, full) > 0; + } + return 0; +} + +int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, + mr_lib_archive_fn archive_pages, void *ud) { if (!out || outsz <= 0) return -1; out[0] = '\0'; @@ -329,8 +427,8 @@ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, parent, name) != 0) continue; struct stat st; - if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; - if (!mr_library_dir_has_images(full)) continue; /* 只算「一本书」,中间目录跳过 */ + if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode)) continue; + if (!sibling_is_book(full, &st, archive_pages, ud)) continue; if (n >= cap) { int nc = cap ? cap * 2 : 16; @@ -360,3 +458,8 @@ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) free(names); return rc; } + +int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) +{ + return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL); +} diff --git a/tuku/logic/library.h b/tuku/logic/library.h index e21268c..ba57c82 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -4,10 +4,12 @@ * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, * 本层只读它来拼副标题,宿主测试用配置存根顶替。) * 书库语义: - * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; - * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); - * - 行的 pages=该目录直接图片数;subdirs=其下含图子目录数(中间目录用于副标题); - * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover; + * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」;M11 起另列直接子文件里的 + * .cbz/.zip(档案册即一本书,与文件夹册同列自然序混排); + * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含书的中间目录(可继续导航); + * - 行的 pages=该目录直接图片数(档案册=档内图片页数);subdirs=其下含图子目录数; + * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover + * (档案册留空,UI 解压档内首图); * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 * * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 @@ -25,13 +27,13 @@ extern "C" { #define MR_LIB_MAX_DEPTH 8 typedef struct mr_library_entry { - char name[MR_NAME_MAX]; /* 目录名(行主标题) */ + char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */ char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ - char cover[MR_PATH_MAX]; /* 缩略图源图(空=none) */ - char subtitle[48]; /* 预先格式化好的 "N pages" / "N folders" */ - int pages; /* 本目录直接图片数 */ + char cover[MR_PATH_MAX]; /* 缩略图源图(空=none;档案册由 UI 解压首图) */ + char subtitle[48]; /* 预先格式化好的 "N pages" / "N books" / "N folders" */ + int pages; /* 本目录直接图片数(档案册=档内图片页数) */ int subdirs; /* 本目录下含图子目录数 */ - int has_images; /* 1=本目录直接含图(点入进 reader) */ + int has_images; /* 1=可直接阅读(直接含图 或 档案册) */ } mr_library_entry; typedef struct mr_library { @@ -43,6 +45,10 @@ typedef struct mr_library { /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ int mr_path_is_image(const char *name); +/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 + * 注意这只是路径判据;能不能当书读还要看档案里有没有图片页(探测回调)。 */ +int mr_path_is_archive(const char *name); + /* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ int mr_natural_cmp(const char *a, const char *b); @@ -52,6 +58,23 @@ int mr_natural_cmp(const char *a, const char *b); * max_depth<=0 或超过 MR_LIB_MAX_DEPTH 时按上限处理。 */ int mr_library_scan(mr_library *lib, const char *dir, int max_depth); +/* ============================================================ */ +/* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */ +/* ------------------------------------------------------------ + * logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。 + * archive_pages(path, ud) 对普通文件返回图片页数(>0=是一本书),0=不是/坏档。 + * 传 NULL 时行为与 mr_library_scan 完全一致(纯目录书库),M7 语义不变。 + * + * 档案册行的口径:name=文件名(含扩展名)、path=档案文件路径、pages=档内页数、 + * has_images=1(点入直接阅读)、cover 留空(缩略图由 UI 解压首图生成)、 + * 副标题 "N pages"、续读角标同文件夹册(按档案路径记)。 + * 只含档案册的子目录算「可导航」(nav),副标题写 "N books"。 + * ============================================================ */ +typedef int (*mr_lib_archive_fn)(const char *path, void *ud); + +int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, + mr_lib_archive_fn archive_pages, void *ud); + /* 释放条目数组并清零(可重复调用)。 */ void mr_library_free(mr_library *lib); @@ -63,12 +86,20 @@ int mr_path_parent(const char *path, char *out, int outsz); * 1=有;0=没有/不可读。 */ int mr_library_dir_has_images(const char *dir); -/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 +/* 册间衔接(M7):同父目录下按书库自然序排列的「可直接阅读」兄弟册。 * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 - * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 */ + * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 + * 只算文件夹册(直接含图目录)——M7 口径不变。 */ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); +/* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」, + * 两者同列按名称自然序混排(mission 基线:跨册衔接按同父目录自然序)。 + * 因此末页前进可以从文件夹册进到同目录的档案册,反之亦然。 + * archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */ +int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, + mr_lib_archive_fn archive_pages, void *ud); + #ifdef __cplusplus } #endif diff --git a/tuku/logic/reader/archive_source.c b/tuku/logic/reader/archive_source.c new file mode 100644 index 0000000..159e891 --- /dev/null +++ b/tuku/logic/reader/archive_source.c @@ -0,0 +1,395 @@ +/* logic/reader/archive_source.c — 档案册(CBZ/zip 当一本书):页源契约的第二种实现。 + * + * 契约、条目过滤与缓存纪律见 page_source.h / page_cache.h。实现要点: + * - 页表在 init 时一次建好(mz_open 只读 central directory;只看名字,不解压); + * - open(i) 才解压单条目:mz_read_alloc → page_cache 落盘 → 注入的解码回调按路径解码。 + * 因此翻页/条漫两套消费代码与目录册完全一致(meta 走原始像素尺寸,open 走解码); + * - meta(i) 按需解压该页到内存只读图片头(不落缓存),结果缓存在 meta_w/meta_h; + * - 单个 mr_zip_book 的 mz_read* 由自带的互斥量串行化(解码线程与主线程 meta 会碰面)。 + * + * 线程:count/page_name 只读页表,可任意线程;open 可由解码线程调用;meta 按 M6 + * 用法在主线程调用(条漫排版/当前页几何)。两个 mr_zip_book 实例互不影响, + * 解压缓存(page_cache)是进程级共享的。 + */ +#include "logic/reader/page_source.h" +#include "logic/reader/page_cache.h" +#include "logic/zip/zip.h" + +#include +#include +#include +#include +#include + +/* ============================================================ */ +/* 条目过滤:图片后缀 + 路径安全 + 非隐藏 + 非目录 */ +/* ============================================================ */ +static int has_control(const char *name, unsigned len) +{ + for (unsigned i = 0; i < len; i++) { + unsigned char c = (unsigned char)name[i]; + if (c < 0x20 || c == 0x7F) return 1; + } + return 0; +} + +/* 逐段检查:拒绝 ".." 段与隐藏段("._resource" 之类不参与成册)。 */ +static int components_safe(const char *name) +{ + const char *p = name; + while (*p) { + const char *end = strchr(p, '/'); + size_t n = end ? (size_t)(end - p) : strlen(p); + if (n == 2 && p[0] == '.' && p[1] == '.') return 0; + if (n > 0 && p[0] == '.') return 0; + if (!end) break; + p = end + 1; + } + return 1; +} + +int mr_zip_entry_is_page(const char *name, unsigned name_len) +{ + if (!name || name_len == 0 || name_len >= MR_ZIP_MAX_ENTRY_NAME) return 0; + if (strlen(name) != name_len) return 0; /* 内嵌 NUL:拒绝 */ + if (name[0] == '/' || name[0] == '\\') return 0; /* 绝对路径 */ + if (name_len >= 2 && name[1] == ':') return 0; /* Windows 盘符 */ + if (strchr(name, '\\')) return 0; /* 反斜杠分隔符:一律拒绝 */ + if (has_control(name, name_len)) return 0; + if (name[name_len - 1] == '/') return 0; /* 目录条目 */ + if (!components_safe(name)) return 0; + return mr_path_is_image(name); +} + +/* 页表排序:跨子目录按完整条目名自然序(中文按 UTF-8 字节序,数字段按数值)。 */ +typedef struct page_ref { + const char *name; + int eidx; +} page_ref; + +static int page_ref_cmp(const void *pa, const void *pb) +{ + const page_ref *a = (const page_ref *)pa; + const page_ref *b = (const page_ref *)pb; + int c = mr_natural_cmp(a->name, b->name); + if (c != 0) return c; + return a->eidx < b->eidx ? -1 : (a->eidx > b->eidx ? 1 : 0); +} + +/* ============================================================ */ +/* 生命周期 */ +/* ============================================================ */ +static int entry_is_page(const mz_entry *e) +{ + if (!e || mz_entry_is_dir(e)) return 0; + if (e->flags & 0x0001u) return 0; /* 加密条目:读不了,不列 */ + if (!mr_zip_entry_is_page(e->name, e->name_len)) return 0; + if (e->uncomp_size == 0) return 0; + if (e->uncomp_size > (uint64_t)MR_ZIP_PAGE_MAX_BYTES) return 0; /* zip 炸弹 */ + return 1; +} + +void mr_zip_book_free(mr_zip_book *zb) +{ + if (!zb) return; + if (zb->arch) { + mz_close((mz_archive *)zb->arch); + zb->arch = NULL; + } + if (zb->lock) { + pthread_mutex_destroy((pthread_mutex_t *)zb->lock); + free(zb->lock); + zb->lock = NULL; + } + free(zb->order); + zb->order = NULL; + free(zb->meta_w); + zb->meta_w = NULL; + free(zb->meta_h); + zb->meta_h = NULL; + zb->count = 0; +} + +int mr_zip_book_init(mr_zip_book *zb, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud) +{ + if (!zb) return -1; + memset(zb, 0, sizeof *zb); + if (!path || !path[0]) return -1; + if (strlen(path) >= sizeof zb->path) return -1; + if (maxdim <= 0) maxdim = 720; + + mz_archive *z = NULL; + if (mz_open(path, &z) != MZ_OK || !z) return -1; + + pthread_mutex_t *lk = (pthread_mutex_t *)malloc(sizeof *lk); + if (!lk) { + mz_close(z); + return -1; + } + pthread_mutex_init(lk, NULL); + + snprintf(zb->path, sizeof zb->path, "%s", path); + zb->arch = z; + zb->lock = lk; + zb->maxdim = maxdim; + zb->load = load; + zb->unload = unload; + zb->ud = ud; + + struct stat st; + if (stat(path, &st) == 0) { + zb->mtime = (long long)st.st_mtime; + zb->size = (long long)st.st_size; + } + + int n = mz_count(z); + page_ref *refs = NULL; + int nref = 0, cap = 0; + for (int i = 0; i < n && nref < MR_BOOK_MAX_PAGES; i++) { + const mz_entry *e = mz_entry_at(z, i); + if (!entry_is_page(e)) continue; + if (nref >= cap) { + int nc = cap ? cap * 2 : 32; + page_ref *nr = (page_ref *)realloc(refs, (size_t)nc * sizeof *nr); + if (!nr) { + free(refs); + mr_zip_book_free(zb); + return -1; + } + refs = nr; + cap = nc; + } + refs[nref].name = e->name; + refs[nref].eidx = i; + nref++; + } + if (nref > 1) qsort(refs, (size_t)nref, sizeof *refs, page_ref_cmp); + if (nref > 0) { + zb->order = (int *)malloc((size_t)nref * sizeof *zb->order); + if (!zb->order) { + free(refs); + mr_zip_book_free(zb); + return -1; + } + for (int i = 0; i < nref; i++) zb->order[i] = refs[i].eidx; + } + free(refs); + zb->count = nref; + return nref; +} + +/* ============================================================ */ +/* 页 → 缓存文件 */ +/* ============================================================ */ +static const mz_entry *page_entry(mr_zip_book *zb, int index) +{ + if (!zb || !zb->arch || !zb->order) return NULL; + if (index < 0 || index >= zb->count) return NULL; + return mz_entry_at((mz_archive *)zb->arch, zb->order[index]); +} + +int mr_zip_book_page_path(mr_zip_book *zb, int index, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + + const mz_entry *e = page_entry(zb, index); + if (!e) return -1; + + char path[MR_CACHE_FILE_MAX]; + if (mr_cache_path(zb->path, zb->mtime, zb->size, e->name, index, + path, (int)sizeof path) != 0) + return -1; + if (mr_cache_touch(path)) { + snprintf(out, (size_t)outsz, "%s", path); + return 0; + } + + pthread_mutex_lock((pthread_mutex_t *)zb->lock); + void *buf = NULL; + size_t len = 0; + int rc = mz_read_alloc((mz_archive *)zb->arch, e, &buf, &len); + pthread_mutex_unlock((pthread_mutex_t *)zb->lock); + if (rc != MZ_OK || !buf) { + free(buf); + return -1; + } + int ok = mr_cache_store(path, buf, len); + free(buf); + if (!ok) return -1; + snprintf(out, (size_t)outsz, "%s", path); + return 0; +} + +void mr_zip_book_page_name(const mr_zip_book *zb, int index, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + out[0] = '\0'; + const mz_entry *e = page_entry((mr_zip_book *)zb, index); + if (!e) return; + snprintf(out, (size_t)outsz, "%s", e->name); +} + +/* ============================================================ */ +/* 书库辅助:数页 / 取封面 */ +/* ============================================================ */ +int mr_zip_count_pages(const char *path) +{ + if (!path || !path[0]) return 0; + mz_archive *z = NULL; + if (mz_open(path, &z) != MZ_OK || !z) return 0; + int n = mz_count(z), pages = 0; + for (int i = 0; i < n && pages < MR_BOOK_MAX_PAGES; i++) + if (entry_is_page(mz_entry_at(z, i))) pages++; + mz_close(z); + return pages; +} + +int mr_zip_cover_path(const char *path, char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + if (!path || !path[0]) return 0; + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 0, NULL, NULL, NULL); + int ok = 0; + if (n > 0) ok = (mr_zip_book_page_path(&zb, 0, out, outsz) == 0); + mr_zip_book_free(&zb); + return ok; +} + +/* ============================================================ */ +/* mr_book_ops 适配器(档案册) */ +/* ============================================================ */ +static int zip_ops_count(void *ctx) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + return zb ? zb->count : 0; +} + +/* meta 按需解压头探测;结果缓存,每页只解压一次。主线程调用(M6 用法)。 */ +static int zip_ops_meta(void *ctx, int index, int *w, int *h) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (w) *w = 0; + if (h) *h = 0; + if (!zb || index < 0 || index >= zb->count) return 0; + + if (!zb->meta_w) { + int *mw = (int *)calloc((size_t)zb->count, sizeof *mw); + int *mh = (int *)calloc((size_t)zb->count, sizeof *mh); + if (!mw || !mh) { + free(mw); + free(mh); + return 0; + } + zb->meta_w = mw; + zb->meta_h = mh; + } + if (zb->meta_w[index] > 0 && zb->meta_h[index] > 0) { + if (w) *w = zb->meta_w[index]; + if (h) *h = zb->meta_h[index]; + return 1; + } + + const mz_entry *e = page_entry(zb, index); + if (!e) return 0; + + pthread_mutex_lock((pthread_mutex_t *)zb->lock); + void *buf = NULL; + size_t len = 0; + int rc = mz_read_alloc((mz_archive *)zb->arch, e, &buf, &len); + pthread_mutex_unlock((pthread_mutex_t *)zb->lock); + if (rc != MZ_OK || !buf) { + free(buf); + return 0; + } + int pw = 0, ph = 0; + int known = mr_probe_size_mem(buf, len, &pw, &ph); + free(buf); + if (!known) return 0; + + zb->meta_w[index] = pw; + zb->meta_h[index] = ph; + if (w) *w = pw; + if (h) *h = ph; + return 1; +} + +static meow_img *zip_ops_open(void *ctx, int index) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (!zb || !zb->load) return NULL; + char path[MR_CACHE_FILE_MAX]; + if (mr_zip_book_page_path(zb, index, path, (int)sizeof path) != 0) return NULL; + return zb->load(path, zb->ud, zb->maxdim); +} + +static void zip_ops_close(void *ctx, meow_img *img) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (zb && zb->unload && img) zb->unload(img); +} + +static void zip_ops_destroy(void *ctx) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (zb) mr_zip_book_free(zb); +} + +static const mr_book_ops g_zip_ops = { + zip_ops_count, zip_ops_meta, zip_ops_open, zip_ops_close, zip_ops_destroy, +}; + +void mr_zip_book_bind(mr_book *b, mr_zip_book *zb) +{ + if (!b) return; + b->ops = zb ? &g_zip_ops : NULL; + b->ctx = zb; +} + +/* ============================================================ */ +/* 统一开册 */ +/* ============================================================ */ +int mr_book_open_auto(mr_book *b, mr_book_handle *h, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud) +{ + if (b) { + b->ops = NULL; + b->ctx = NULL; + } + if (!b || !h) return -1; + memset(h, 0, sizeof *h); + if (!path || !path[0]) return -1; + + int n; + /* 目录名也可能以 .cbz 结尾(罕见):只有普通文件才当档案册。 */ + int is_archive = mr_path_is_archive(path); + if (is_archive) { + struct stat st; + if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) is_archive = 0; + } + if (is_archive) { + n = mr_zip_book_init(&h->u.zip, path, maxdim, load, unload, ud); + if (n < 0) return -1; + h->kind = MR_BOOK_KIND_ZIP; + if (n > 0) mr_zip_book_bind(b, &h->u.zip); + } else { + n = mr_dir_book_init(&h->u.dir, path, maxdim, load, unload, ud); + if (n < 0) return -1; + h->kind = MR_BOOK_KIND_DIR; + if (n > 0) mr_dir_book_bind(b, &h->u.dir); + } + return n; +} + +void mr_book_close_auto(mr_book *b, mr_book_handle *h) +{ + if (b) mr_book_destroy(b); + if (!h) return; + if (h->kind == MR_BOOK_KIND_ZIP) mr_zip_book_free(&h->u.zip); + else if (h->kind == MR_BOOK_KIND_DIR) mr_dir_book_free(&h->u.dir); + memset(h, 0, sizeof *h); +} diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c new file mode 100644 index 0000000..7075398 --- /dev/null +++ b/tuku/logic/reader/page_cache.c @@ -0,0 +1,350 @@ +/* logic/reader/page_cache.c — 解压页磁盘缓存实现,见 page_cache.h 的纪律说明。 */ +#include "logic/reader/page_cache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct cache_entry { + char path[MR_CACHE_FILE_MAX]; + long long size; + unsigned long long used; /* LRU 计数(越大越新) */ +} cache_entry; + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static char g_dir[MR_CACHE_DIR_MAX]; +static int g_ready; +static cache_entry g_idx[MR_CACHE_MAX_PAGES]; +static int g_n; +static long long g_bytes; +static unsigned long long g_tick; + +/* ============================================================ */ +/* 目录推导 */ +/* ============================================================ */ +int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz) +{ + if (!data_dir || !data_dir[0] || !out || outsz <= 0) return -1; + + size_t n = strlen(data_dir); + while (n > 1 && data_dir[n - 1] == '/') n--; + if (n >= (size_t)outsz) return -1; + memcpy(out, data_dir, n); + out[n] = '\0'; + + /* 末段是 "data" 时换成 "cache"(数据目录 → 同级缓存目录)。 */ + char *slash = strrchr(out, '/'); + if (slash && strcmp(slash + 1, "data") == 0) *slash = '\0'; + + n = strlen(out); + if (n == 0) { + if (outsz < 7) return -1; + memcpy(out, "/cache", 7); + return 0; + } + int w = snprintf(out + n, (size_t)(outsz - (int)n), "/cache"); + if (w <= 0 || w >= outsz - (int)n) { + out[0] = '\0'; + return -1; + } + return 0; +} + +const char *mr_cache_dir(void) +{ + return g_ready ? g_dir : ""; +} + +int mr_cache_ready(void) +{ + return g_ready; +} + +/* ============================================================ */ +/* 索引(调用方持锁) */ +/* ============================================================ */ +static int idx_find_locked(const char *path) +{ + for (int i = 0; i < g_n; i++) + if (strcmp(g_idx[i].path, path) == 0) return i; + return -1; +} + +static void idx_remove_locked(int i) +{ + if (i < 0 || i >= g_n) return; + g_bytes -= g_idx[i].size; + g_idx[i] = g_idx[g_n - 1]; + g_n--; +} + +static void idx_evict_locked(void) +{ + if (g_n <= 0) return; + int oldest = 0; + for (int i = 1; i < g_n; i++) + if (g_idx[i].used < g_idx[oldest].used) oldest = i; + unlink(g_idx[oldest].path); + idx_remove_locked(oldest); +} + +static void make_room_locked(long long len) +{ + while (g_n > 0 && (g_n >= MR_CACHE_MAX_PAGES || + g_bytes + len > MR_CACHE_MAX_BYTES)) + idx_evict_locked(); +} + +static void idx_add_locked(const char *path, long long size) +{ + if (g_n >= MR_CACHE_MAX_PAGES) return; + cache_entry *e = &g_idx[g_n++]; + snprintf(e->path, sizeof e->path, "%s", path); + e->size = size; + g_tick++; + e->used = g_tick; + g_bytes += size; +} + +/* 上一进程遗留的解压页:索引在内存里,磁盘上的 mrpage_* 全是孤儿。 */ +static void clean_orphans_locked(void) +{ + DIR *d = opendir(g_dir); + if (!d) return; + size_t plen = strlen(MR_CACHE_PREFIX); + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strncmp(de->d_name, MR_CACHE_PREFIX, plen) != 0) continue; + char full[MR_CACHE_FILE_MAX]; + int w = snprintf(full, sizeof full, "%s/%s", g_dir, de->d_name); + if (w > 0 && w < (int)sizeof full) unlink(full); + } + closedir(d); +} + +/* ============================================================ */ +/* 生命周期 */ +/* ============================================================ */ +/* 逐级建目录(数据目录的父级可能还不存在;只建到缓存目录为止)。 */ +static int mkdir_p(const char *path) +{ + char tmp[MR_CACHE_DIR_MAX]; + size_t n = strlen(path); + if (n == 0 || n >= sizeof tmp) return -1; + memcpy(tmp, path, n + 1); + for (size_t i = 1; i <= n; i++) { + if (tmp[i] != '/' && tmp[i] != '\0') continue; + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0 && errno != EEXIST) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + return 0; +} + +int mr_cache_init(const char *data_dir) +{ + char dir[MR_CACHE_DIR_MAX]; + if (mr_cache_dir_from_data(data_dir, dir, (int)sizeof dir) != 0) return -1; + + pthread_mutex_lock(&g_lock); + if (g_ready && strcmp(g_dir, dir) == 0) { + pthread_mutex_unlock(&g_lock); + return 0; + } + + g_ready = 0; + g_n = 0; + g_bytes = 0; + g_dir[0] = '\0'; + + if (mkdir_p(dir) != 0) { + pthread_mutex_unlock(&g_lock); + return -1; + } + struct stat st; + if (stat(dir, &st) != 0 || !S_ISDIR(st.st_mode)) { + pthread_mutex_unlock(&g_lock); + return -1; + } + snprintf(g_dir, sizeof g_dir, "%s", dir); + clean_orphans_locked(); + g_tick = 0; + g_ready = 1; + pthread_mutex_unlock(&g_lock); + return 0; +} + +void mr_cache_shutdown(void) +{ + pthread_mutex_lock(&g_lock); + g_ready = 0; + g_n = 0; + g_bytes = 0; + g_tick = 0; + g_dir[0] = '\0'; + pthread_mutex_unlock(&g_lock); +} + +/* ============================================================ */ +/* 键 / 命中 / 写入 */ +/* ============================================================ */ +static unsigned long long hash_bytes(unsigned long long h, const void *p, size_t n) +{ + const unsigned char *b = (const unsigned char *)p; + for (size_t i = 0; i < n; i++) { + h ^= (unsigned long long)b[i]; + h *= 1099511628211ULL; + } + return h; +} + +static unsigned long long cache_hash(const char *archive, long long mtime, + long long size, const char *entry, int page) +{ + char num[64]; + unsigned long long h = 1469598103934665603ULL; + h = hash_bytes(h, archive, strlen(archive) + 1); + int n = snprintf(num, sizeof num, "%lld", mtime); + h = hash_bytes(h, num, (size_t)n + 1); + n = snprintf(num, sizeof num, "%lld", size); + h = hash_bytes(h, num, (size_t)n + 1); + h = hash_bytes(h, entry, strlen(entry) + 1); + n = snprintf(num, sizeof num, "%d", page); + h = hash_bytes(h, num, (size_t)n + 1); + return h; +} + +/* 保留图片扩展名:解码入口按 .jpg/.jpeg 区分「解码期缩解」路径。 */ +static void entry_ext(const char *entry, char *out, int outsz) +{ + const char *dot = entry ? strrchr(entry, '.') : NULL; + const char *ext = dot ? dot + 1 : ""; + if ((strcasecmp(ext, "jpg") == 0) || (strcasecmp(ext, "jpeg") == 0) || + (strcasecmp(ext, "png") == 0) || (strcasecmp(ext, "bmp") == 0)) { + int i = 0; + for (; ext[i] && i < outsz - 1; i++) out[i] = (char)tolower((unsigned char)ext[i]); + out[i] = '\0'; + return; + } + snprintf(out, (size_t)outsz, "img"); +} + +int mr_cache_path(const char *archive, long long mtime, long long size, + const char *entry, int page_index, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!archive || !archive[0] || !entry || !entry[0]) return -1; + + char ext[8]; + entry_ext(entry, ext, (int)sizeof ext); + + pthread_mutex_lock(&g_lock); + if (!g_ready) { + pthread_mutex_unlock(&g_lock); + return -1; + } + unsigned long long h = cache_hash(archive, mtime, size, entry, page_index); + int n = snprintf(out, (size_t)outsz, "%s/%s%016llx.%s", g_dir, MR_CACHE_PREFIX, h, ext); + pthread_mutex_unlock(&g_lock); + if (n <= 0 || n >= outsz) { + out[0] = '\0'; + return -1; + } + return 0; +} + +int mr_cache_touch(const char *path) +{ + if (!path || !path[0]) return 0; + + pthread_mutex_lock(&g_lock); + if (!g_ready) { + pthread_mutex_unlock(&g_lock); + return 0; + } + int i = idx_find_locked(path); + if (i >= 0) { + g_tick++; + g_idx[i].used = g_tick; + pthread_mutex_unlock(&g_lock); + return 1; + } + /* 索引里没有(外部写入/索引满):磁盘上有就算命中,能装进索引就装。 */ + struct stat st; + int ok = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; + if (ok && g_n < MR_CACHE_MAX_PAGES && strlen(path) < MR_CACHE_FILE_MAX) + idx_add_locked(path, (long long)st.st_size); + pthread_mutex_unlock(&g_lock); + return ok ? 1 : 0; +} + +int mr_cache_store(const char *path, const void *data, size_t len) +{ + if (!path || !path[0] || !data || len == 0) return 0; + if (strlen(path) >= MR_CACHE_FILE_MAX) return 0; + + char tmp[MR_CACHE_FILE_MAX + 8]; + int w = snprintf(tmp, sizeof tmp, "%s.part", path); + if (w <= 0 || w >= (int)sizeof tmp) return 0; + + pthread_mutex_lock(&g_lock); + if (!g_ready) { + pthread_mutex_unlock(&g_lock); + return 0; + } + int i = idx_find_locked(path); + if (i >= 0) idx_remove_locked(i); + make_room_locked((long long)len); + pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞缩略图线程 */ + + FILE *f = fopen(tmp, "wb"); + int ok = f && fwrite(data, 1, len, f) == len; + if (f && fclose(f) != 0) ok = 0; + if (!ok) { + unlink(tmp); + return 0; + } + if (rename(tmp, path) != 0) { + unlink(tmp); + return 0; + } + + pthread_mutex_lock(&g_lock); + if (g_ready) { + i = idx_find_locked(path); + if (i >= 0) idx_remove_locked(i); + make_room_locked((long long)len); /* 写盘期间可能被别的线程填满 */ + idx_add_locked(path, (long long)len); + g_tick++; + } + pthread_mutex_unlock(&g_lock); + return 1; +} + +int mr_cache_count(void) +{ + pthread_mutex_lock(&g_lock); + int n = g_n; + pthread_mutex_unlock(&g_lock); + return n; +} + +long long mr_cache_bytes(void) +{ + pthread_mutex_lock(&g_lock); + long long b = g_bytes; + pthread_mutex_unlock(&g_lock); + return b; +} diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h new file mode 100644 index 0000000..51df42e --- /dev/null +++ b/tuku/logic/reader/page_cache.h @@ -0,0 +1,68 @@ +/* logic/reader/page_cache.h — 档案册解压页的磁盘缓存(纯 C,不依赖 SDK)。 + * + * 为什么需要磁盘缓存:SDK 的图像解码入口只吃文件路径(meow_img_load_file_dim), + * 而 zip 里的页是压缩流。档案页源在 open(i) 时把条目解压成缓存文件,再用同一个 + * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 + * + * 目录与上限(纪律): + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picobrowser/data, + * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); + * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页); + * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 + * + * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 + * 同名条目重复出现时不会命中旧内容。文件名保留图片扩展名(解码入口按扩展名区分 + * JPEG 的「解码期缩解」路径)。 + * + * 线程:内部互斥量;可在解码线程/缩略图线程/主线程并发调用。 + * 纯 C:宿主测试可直接编译(见 tuku/test/test_cbz.c)。 + */ +#ifndef MR_PAGE_CACHE_H +#define MR_PAGE_CACHE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_CACHE_MAX_PAGES 64 +#define MR_CACHE_MAX_BYTES (32LL * 1024 * 1024) +#define MR_CACHE_PREFIX "mrpage_" +#define MR_CACHE_DIR_MAX 200 +#define MR_CACHE_FILE_MAX 256 + +/* 数据目录 → 缓存目录(纯字符串):末尾的 "data" 段替换为 "cache"。 + * 例:/…/MeowData/os.meow.picobrowser/data → /…/MeowData/os.meow.picobrowser/cache。 + * 成功 0;参数非法/缓冲不足 -1。 */ +int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); + +/* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 + * 同一目录重复调用为无操作(不会删掉正在用的页)。 */ +int mr_cache_init(const char *data_dir); + +/* 释放索引(不删文件);测试用。可重复调用。 */ +void mr_cache_shutdown(void); + +int mr_cache_ready(void); +const char *mr_cache_dir(void); /* 未初始化返回空串 */ + +/* 计算某页缓存文件路径(不碰磁盘)。扩展名取自 entry 名(未知时 .img)。 */ +int mr_cache_path(const char *archive, long long mtime, long long size, + const char *entry, int page_index, char *out, int outsz); + +/* 缓存命中:文件在索引里(或磁盘上存在)则刷新 LRU 并返回 1,否则 0。 */ +int mr_cache_touch(const char *path); + +/* 写入缓存并纳入 LRU;1=成功。len==0 视为失败(空页没有解码价值)。 */ +int mr_cache_store(const char *path, const void *data, size_t len); + +/* 观测(宿主测试断言 LRU 纪律用):当前索引页数 / 字节数。 */ +int mr_cache_count(void); +long long mr_cache_bytes(void); + +#ifdef __cplusplus +} +#endif +#endif /* MR_PAGE_CACHE_H */ diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c index f33297d..abab520 100644 --- a/tuku/logic/reader/page_source.c +++ b/tuku/logic/reader/page_source.c @@ -146,16 +146,56 @@ int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim, /* ============================================================ */ /* 图片头探测(不解码像素) */ -/* ============================================================ */ +/* ------------------------------------------------------------ + * 同一套解析同时服务「文件」(目录册,流式读不占内存)和「内存缓冲」 + * (档案册:page_cache 解压后直接探测,不必再落盘读文件)。 + * ============================================================ */ +typedef struct probe_src { + const unsigned char *mem; /* 非 NULL = 内存源 */ + size_t len, pos; + FILE *f; /* mem == NULL 时用 */ +} probe_src; + +static int ps_read(probe_src *s, void *buf, size_t n) +{ + if (s->mem) { + if (n > s->len - s->pos) return 0; + memcpy(buf, s->mem + s->pos, n); + s->pos += n; + return 1; + } + return fread(buf, 1, n, s->f) == n; +} + +static int ps_getc(probe_src *s) +{ + if (s->mem) { + if (s->pos >= s->len) return EOF; + return s->mem[s->pos++]; + } + return fgetc(s->f); +} + +static int ps_skip(probe_src *s, long n) +{ + if (n < 0) return 0; + if (s->mem) { + if ((size_t)n > s->len - s->pos) return 0; + s->pos += (size_t)n; + return 1; + } + return fseek(s->f, n, SEEK_CUR) == 0; +} + static unsigned probe_be32(const unsigned char *p) { return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3]; } -static int probe_png(FILE *f, int *w, int *h) +static int probe_png(probe_src *s, int *w, int *h) { unsigned char hdr[24]; - if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + if (!ps_read(s, hdr, sizeof hdr)) return 0; static const unsigned char sig[8] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }; if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0; unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20); @@ -165,27 +205,27 @@ static int probe_png(FILE *f, int *w, int *h) return 1; } -static int probe_jpeg(FILE *f, int *w, int *h) +static int probe_jpeg(probe_src *s, int *w, int *h) { unsigned char b[2]; - if (fread(b, 1, 2, f) != 2 || b[0] != 0xFF || b[1] != 0xD8) return 0; + if (!ps_read(s, b, 2) || b[0] != 0xFF || b[1] != 0xD8) return 0; for (;;) { - int c = fgetc(f); + int c = ps_getc(s); if (c == EOF) return 0; if (c != 0xFF) continue; - int m = fgetc(f); - while (m == 0xFF) m = fgetc(f); /* 填充字节 */ + int m = ps_getc(s); + while (m == 0xFF) m = ps_getc(s); /* 填充字节 */ if (m == EOF) return 0; if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */ if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */ unsigned char lb[2]; - if (fread(lb, 1, 2, f) != 2) return 0; + if (!ps_read(s, lb, 2)) return 0; int len = ((int)lb[0] << 8) | lb[1]; if (len < 2) return 0; int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; if (is_sof) { unsigned char so[5]; - if (len < 7 || fread(so, 1, 5, f) != 5) return 0; + if (len < 7 || !ps_read(s, so, 5)) return 0; int hh = ((int)so[1] << 8) | so[2]; int ww = ((int)so[3] << 8) | so[4]; if (ww <= 0 || hh <= 0) return 0; @@ -193,14 +233,14 @@ static int probe_jpeg(FILE *f, int *w, int *h) *h = hh; return 1; } - if (fseek(f, len - 2, SEEK_CUR) != 0) return 0; + if (!ps_skip(s, len - 2)) return 0; } } -static int probe_bmp(FILE *f, int *w, int *h) +static int probe_bmp(probe_src *s, int *w, int *h) { unsigned char hdr[26]; - if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + if (!ps_read(s, hdr, sizeof hdr)) return 0; if (hdr[0] != 'B' || hdr[1] != 'M') return 0; int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) | ((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24)); @@ -213,6 +253,32 @@ static int probe_bmp(FILE *f, int *w, int *h) return 1; } +static int probe_any(probe_src *s, int *w, int *h) +{ + for (int pass = 0; pass < 3; pass++) { + s->pos = 0; + if (s->f) rewind(s->f); + int ok = (pass == 0) ? probe_png(s, w, h) + : (pass == 1) ? probe_jpeg(s, w, h) + : probe_bmp(s, w, h); + if (ok) return 1; + } + return 0; +} + +int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!buf || len < 8 || !w || !h) return 0; + probe_src s = { (const unsigned char *)buf, len, 0, NULL }; + if (!probe_any(&s, w, h) || *w <= 0 || *h <= 0) { + *w = *h = 0; + return 0; + } + return 1; +} + int mr_probe_size(const char *path, int *w, int *h) { if (w) *w = 0; @@ -220,9 +286,8 @@ int mr_probe_size(const char *path, int *w, int *h) if (!path || !path[0] || !w || !h) return 0; FILE *f = fopen(path, "rb"); if (!f) return 0; - int ok = probe_png(f, w, h); - if (!ok) { rewind(f); ok = probe_jpeg(f, w, h); } - if (!ok) { rewind(f); ok = probe_bmp(f, w, h); } + probe_src s = { NULL, 0, 0, f }; + int ok = probe_any(&s, w, h); fclose(f); if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; } return ok; diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h index fe79858..b582278 100644 --- a/tuku/logic/reader/page_source.h +++ b/tuku/logic/reader/page_source.h @@ -18,6 +18,7 @@ #define MR_PAGE_SOURCE_H #include "logic/library.h" /* mr_path_is_image / mr_natural_cmp / MR_PATH_MAX / MR_NAME_MAX */ +#include /* size_t(mr_probe_size_mem) */ #ifdef __cplusplus extern "C" { @@ -84,6 +85,83 @@ void mr_dir_book_bind(mr_book *b, mr_dir_book *db); /* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。 * PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */ int mr_probe_size(const char *path, int *w, int *h); +/* 同上,但从内存缓冲探测(档案页解压后不需要落盘再读一遍)。 */ +int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h); + +/* ============================================================ */ +/* 档案册(M11):.cbz / .zip 当一本书 */ +/* ------------------------------------------------------------ + * 目录册之外的第二种 mr_book 实现,契约完全相同。条目名按 zip 原始字节 + * 透传(通常 UTF-8):只收「图片后缀 + 路径安全 + 非隐藏 + 非目录」的条目, + * 跨子目录按 mr_natural_cmp 自然排序。open(i) 先把条目解压进 + * logic/reader/page_cache(磁盘 LRU),再用注入的解码回调按路径解码—— + * 与目录册走同一条 UI 解码/预算路径,翻页与条漫两种模式零改动复用。 + * + * 条目名永远不会被拼成文件系统路径(缓存文件名是哈希),路径安全过滤是 + * 纵深防御:拒绝绝对路径、'..' 段、反斜杠与内嵌 NUL。 + * ============================================================ */ +#define MR_ZIP_MAX_ENTRY_NAME 1024 /* 超长条目名跳过(防病态包) */ +#define MR_ZIP_PAGE_MAX_BYTES (64LL * 1024 * 1024) /* 单页解压上限(防 zip 炸弹) */ + +typedef struct mr_zip_book { + char path[MR_PATH_MAX]; + void *arch; /* mz_archive*(zip 层句柄;不对外暴露) */ + void *lock; /* pthread_mutex_t*:mz_read* 串行化(open/meta 共用) */ + int *order; /* 页 i → zip 条目下标(堆分配) */ + int *meta_w; /* 页原始宽/高(按需解压头探测;NULL=未探测) */ + int *meta_h; + int count; + int maxdim; + long long mtime; /* 档案 mtime/size:解压缓存键的一部分 */ + long long size; + mr_book_load_fn load; /* 可为 NULL(只取封面/页路径时不需要解码) */ + mr_book_unload_fn unload; + void *ud; +} mr_zip_book; + +/* 打开档案并建立页表。返回页数(>=0);参数非法/坏档/空档案不可读返回 -1 + * (zb 已清空、可安全 free)。0 页的合法档案返回 0(zb 仍持有打开的 archive)。 */ +int mr_zip_book_init(mr_zip_book *zb, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud); +void mr_zip_book_free(mr_zip_book *zb); +/* 把档案册绑定到通用 mr_book(zb 生命周期由调用方持有)。 */ +void mr_zip_book_bind(mr_book *b, mr_zip_book *zb); +/* 第 index 页在 zip 内的条目名(原始字节);越界输出空串。 */ +void mr_zip_book_page_name(const mr_zip_book *zb, int index, char *out, int outsz); +/* 确保第 index 页已解压到缓存并返回缓存文件路径;越界/坏档/缓存不可用 -1。 */ +int mr_zip_book_page_path(mr_zip_book *zb, int index, char *out, int outsz); + +/* 只数页(书库扫描/邻册判断用;不解压、不建缓存)。0=不是档案或没有图片页。 */ +int mr_zip_count_pages(const char *path); +/* 档案封面:档内自然序第一张图解到缓存,路径写入 out。1=成功。 */ +int mr_zip_cover_path(const char *path, char *out, int outsz); +/* 条目是否算一页(图片后缀 + 路径安全 + 非隐藏 + 非目录)。1=是。 */ +int mr_zip_entry_is_page(const char *name, unsigned name_len); + +/* ============================================================ */ +/* 统一开册(目录册 / 档案册) */ +/* ------------------------------------------------------------ + * 书库点入的行既可能是目录也可能是 .cbz/.zip;阅读器只调这里,不关心 provider。 + * 句柄由调用方提供(阅读器用静态变量),生命周期 = 打开到 close_auto。 + * ============================================================ */ +#define MR_BOOK_KIND_NONE 0 +#define MR_BOOK_KIND_DIR 1 +#define MR_BOOK_KIND_ZIP 2 + +typedef struct mr_book_handle { + int kind; + union { + mr_dir_book dir; + mr_zip_book zip; + } u; +} mr_book_handle; + +/* 按路径自动选 provider 并绑定 b。返回页数(>=0)或 -1(打不开)。 + * 页数为 0 时 b 不绑定(空册),句柄仍可安全 mr_book_close_auto。 */ +int mr_book_open_auto(mr_book *b, mr_book_handle *h, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud); +/* 释放 provider(先 mr_book_destroy 再释放各自索引);可重复调用。 */ +void mr_book_close_auto(mr_book *b, mr_book_handle *h); #ifdef __cplusplus } diff --git a/tuku/test/make_cbz_samples.py b/tuku/test/make_cbz_samples.py new file mode 100755 index 0000000..10ea21a --- /dev/null +++ b/tuku/test/make_cbz_samples.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""生成 tuku 档案册(CBZ)集成测试夹具:真实 zipfile 产物 + 定点字节修补。 + +用法: python3 make_cbz_samples.py + +产出: + /library/ 书库夹具(文件夹册 + 档案册混排) + bookA/001.png 002.png 文件夹册(2 页,PNG) + vol2.cbz 3 页:PNG(41x31) / PNG(40x30) / 中文名 PNG(12x34) + + 非图片、目录项、0 字节条目(都应被过滤) + Vol10.cbz 1 页 BMP(7x9),stored + box/box1.cbz box2.cbz 只含档案册的子目录(书库行应写 "2 books") + notabook.zip 只有文本 → 不是书 + emptypages.cbz 目录项 + 文本 → 不是书 + broken.cbz 非 zip 字节 → 不是书 + truncated.cbz vol2 砍半(EOCD 丢失) → 不是书 + .hidden.cbz 隐藏名 → 不参与 + /features/ + pathsafe.cbz 安全/不安全条目混排(只应收 2 个安全图片页) + cjk.cbz 中文名自然序(第1页 < 第2页 < 第10页) + big.cbz 80 页 8x8 PNG(解压缓存 LRU 上限用) + bomb.cbz CD 声明单页 96MiB(应被过滤,防 zip 炸弹) + /manifest.txt 每档案每条目一行:<档案相对路径>\t<条目名>\t\t + +图片内容都是结构合法的 PNG/BMP/JPEG 头(PNG 是真图),C 测试用假解码器, +只校验解压字节与 manifest 的 CRC/尺寸一致、以及 meta 探测出的原始尺寸。 +""" +import os +import struct +import sys +import zipfile +import zlib + +DATE = (2024, 1, 1, 0, 0, 0) + + +# ---------------------------------------------------------------- 图片构造 +def png_bytes(w, h, seed=0): + raw = bytearray() + for y in range(h): + raw.append(0) # filter: none + for x in range(w): + raw += bytes(((x * 7 + y * 13 + seed) & 0xFF, + (x * 3 + y * 5 + seed) & 0xFF, + (x + y + seed) & 0xFF)) + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) # 8-bit RGB + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) + + +def bmp_bytes(w, h): + stride = (w * 3 + 3) & ~3 + px = bytes(((i * 11) & 0xFF) for i in range(stride * h)) + hdr = b"BM" + struct.pack("H", 16) + + b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + dqt = b"\xFF\xDB" + struct.pack(">H", 67) + b"\x00" + bytes(64) + sof = b"\xFF\xC0" + struct.pack(">HBHHB", 11, 8, h, w, 1) + b"\x01\x11\x00" + return b"\xFF\xD8" + app0 + dqt + sof + b"\xFF\xD9" + + +# ---------------------------------------------------------------- zip 构造 +def write_zip(path, entries): + """entries: [(name, data, zipfile 压缩方式)]""" + with zipfile.ZipFile(path, "w") as z: + for name, data, method in entries: + zi = zipfile.ZipInfo(name, DATE) + zi.compress_type = method + z.writestr(zi, data) + + +def find_cd_record(data, name): + eocd = data.rfind(b"PK\x05\x06") + if eocd < 0: + raise SystemExit("generator: no EOCD") + total, _cd_size, cd_off = struct.unpack_from(" 64MiB 上限 + with open(bomb, "wb") as f: + f.write(bytes(data)) + + raw = open(os.path.join(feat, "pathsafe.cbz"), "rb").read() + with open(os.path.join(feat, "truncated.cbz"), "wb") as f: + f.write(raw[:len(raw) // 2]) + + +def write_manifest(ro): + """每档案每条目:<相对路径>\t<条目名>\t\t(Tab 分隔,UTF-8 原名)。""" + lines = [] + for base, _dirs, files in os.walk(ro): + for name in sorted(files): + if not name.lower().endswith((".cbz", ".zip")): + continue + path = os.path.join(base, name) + rel = os.path.relpath(path, ro) + try: + with zipfile.ZipFile(path) as z: + for info in z.infolist(): + if info.is_dir(): + continue + payload = z.read(info) + lines.append("%s\t%s\t%08x\t%d" % ( + rel, info.filename, zlib.crc32(payload) & 0xFFFFFFFF, + len(payload))) + except zipfile.BadZipFile: + continue + with open(os.path.join(ro, "manifest.txt"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("usage: make_cbz_samples.py ") + ro = sys.argv[1] + os.makedirs(ro, exist_ok=True) + build_library(ro) + build_features(ro) + write_manifest(ro) + + +if __name__ == "__main__": + main() diff --git a/tuku/test/run_cbz_tests.sh b/tuku/test/run_cbz_tests.sh new file mode 100755 index 0000000..43a0d07 --- /dev/null +++ b/tuku/test/run_cbz_tests.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# run_cbz_tests.sh — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/设备)。 +# 夹具由 test/make_cbz_samples.py 现场生成到 $TMPDIR 临时目录,结束后删除。 +set -e +cd "$(dirname "$0")/.." +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" +trap 'rm -f test/test_cbz' EXIT +gcc -Wall -Wextra -Werror -O1 -I. -I"$SDK" \ + test/test_cbz.c logic/library.c logic/reader.c \ + logic/reader/page_source.c logic/reader/archive_source.c \ + logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \ + -lz -o test/test_cbz +./test/test_cbz ./test/make_cbz_samples.py diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 8be17e3..91a0e78 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -52,3 +52,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then else echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi + +# ---- 档案册(M11):zip 容器 + CBZ 书库/阅读器集成 ---- +./test/run_zip_tests.sh +./test/run_cbz_tests.sh diff --git a/tuku/test/run_webtoon_tests.sh b/tuku/test/run_webtoon_tests.sh index 4fd3e79..81f5d9d 100755 --- a/tuku/test/run_webtoon_tests.sh +++ b/tuku/test/run_webtoon_tests.sh @@ -31,7 +31,9 @@ gcc $CFLAGS test/test_cont_adjacent.c logic/library.c logic/reader.c \ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then gcc $CFLAGS -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I"$SDK/include" \ test/test_mode_switch_host.c ui/reader/reader.c \ - logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ @@ -40,7 +42,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ - -pthread -lm -o "$OUT/test_mode_switch_host" + -pthread -lm -lz -o "$OUT/test_mode_switch_host" "$OUT/test_mode_switch_host" else echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c new file mode 100644 index 0000000..257d682 --- /dev/null +++ b/tuku/test/test_cbz.c @@ -0,0 +1,847 @@ +/* test_cbz.c — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/硬件)。 + * + * 夹具由 test/make_cbz_samples.py 用 python3 zipfile 现场生成(真实 zip 产物 + + * 定点字节修补),放在 $TMPDIR 下临时目录,结束后删除。 + * + * 覆盖: + * T1 扩展名判据:cbz/zip 大小写不敏感;cbr/gz/无扩展名不算档案。 + * T2 页枚举:只收图片页(目录项/文本/0 字节/加密/超限过滤),跨子目录自然序; + * 中文条目名按原始 UTF-8 字节透传。 + * T3 路径安全:绝对路径、'..' 段、反斜杠、内嵌隐藏段一律不收;解压字节与 + * manifest 的 CRC/尺寸一致,缓存文件落在 MeowData//cache。 + * T4 meta:PNG/BMP/JPEG 原始尺寸经「解压到内存只读头」探测(翻页/条漫排版共用)。 + * T5 解压缓存纪律:64 页 LRU 淘汰最旧、命中不重写(inode 不变)、32MiB 字节上限、 + * 启动清孤儿(只删 mrpage_*,不动缩略图 *.bmp)、缓存不可用时优雅失败。 + * T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。 + * T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy), + * 越界 open 返回 NULL,close_auto 幂等。 + * T8 书库列档:.cbz 与文件夹册同列自然序(bookA < box < vol2.cbz < Vol10.cbz), + * 行=文件名+页数;坏档/无图档案不列;只含档案册的目录写 "N books"; + * 不注入探测回调时行为与 M7 完全一致(纯目录书库)。 + * T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz), + * 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。 + * T10 续读角标:档案路径作为册标识,副标题后缀「· 续读 Pn」。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \ + * logic/reader/page_source.c logic/reader/archive_source.c \ + * logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \ + * -lz -o test/test_cbz && ./test/test_cbz + * 或 test/run_cbz_tests.sh。 + */ +#define _POSIX_C_SOURCE 200809L + +#include "logic/library.h" +#include "logic/reader.h" +#include "logic/reader/page_cache.h" +#include "logic/reader/page_source.h" +#include "test/settings_reader_stub.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define PATH_CAP 1024 +#define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */ + +static int g_pass; +static int g_fail; +static char g_root[PATH_CAP]; /* 夹具根 */ +static char g_lib[PATH_CAP]; /* /library */ +static char g_feat[PATH_CAP]; /* /features */ +static char g_data[PATH_CAP]; /* /app/os.meow.picobrowser/data */ +static const char *g_argv0; + +#define CHECK(cond, ...) do { \ + if (cond) { \ + g_pass++; \ + } else { \ + g_fail++; \ + printf("FAIL %s:%d ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ +} while (0) + +/* ============================================================ */ +/* 夹具/文件辅助 */ +/* ============================================================ */ +static void joinp(char *out, size_t cap, const char *a, const char *b) +{ + snprintf(out, cap, "%s/%s", a, b); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[PATH_CAP]; + joinp(full, sizeof full, path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static int make_temp_dir(void) +{ + const char *bases[3]; + int nb = 0; + const char *env = getenv("TMPDIR"); + if (env && env[0]) bases[nb++] = env; + bases[nb++] = "/data/data/com.termux/files/usr/tmp"; + bases[nb++] = "/tmp"; + + for (int i = 0; i < nb; i++) { + char tmpl[PATH_CAP]; + snprintf(tmpl, sizeof tmpl, "%s/tuku_cbz_XXXXXX", bases[i]); + if (mkdtemp(tmpl)) { + snprintf(g_root, sizeof g_root, "%s", tmpl); + return 1; + } + } + return 0; +} + +static const char *find_generator(char *buf, size_t cap) +{ + static const char *fallbacks[] = { + "test/make_cbz_samples.py", "make_cbz_samples.py", + }; + const char *a0 = g_argv0 ? g_argv0 : ""; + const char *slash = strrchr(a0, '/'); + if (a0[0] && strcmp(a0, "-") != 0 && slash) { + snprintf(buf, cap, "%.*s/make_cbz_samples.py", (int)(slash - a0), a0); + if (access(buf, R_OK) == 0) return buf; + } + for (size_t i = 0; i < sizeof fallbacks / sizeof fallbacks[0]; i++) + if (access(fallbacks[i], R_OK) == 0) return fallbacks[i]; + return fallbacks[0]; +} + +static int run_generator(const char *gen) +{ + char cmd[PATH_CAP * 2 + 32]; + snprintf(cmd, sizeof cmd, "python3 '%s' '%s'", gen, g_root); + int st = system(cmd); + return st != -1 && WIFEXITED(st) && WEXITSTATUS(st) == 0; +} + +static int file_crc(const char *path, unsigned long *crc, long *size) +{ + FILE *f = fopen(path, "rb"); + if (!f) return 0; + unsigned char buf[8192]; + uLong c = crc32(0L, Z_NULL, 0); + long total = 0; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) { + c = crc32(c, buf, (uInt)n); + total += (long)n; + } + fclose(f); + if (crc) *crc = (unsigned long)c; + if (size) *size = total; + return 1; +} + +/* manifest.txt 行:<档案相对路径>\t<条目名>\t\t */ +static int manifest_lookup(const char *rel, const char *entry, + unsigned long *crc, long *size) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_root, "manifest.txt"); + FILE *f = fopen(path, "r"); + if (!f) return 0; + char line[PATH_CAP * 2]; + char m_rel[PATH_CAP], m_ent[512]; + unsigned long m_crc; + long m_size; + int found = 0; + while (fgets(line, sizeof line, f) != NULL) { + if (sscanf(line, "%1023[^\t]\t%511[^\t]\t%lx\t%ld", + m_rel, m_ent, &m_crc, &m_size) != 4) + continue; + if (strcmp(m_rel, rel) == 0 && strcmp(m_ent, entry) == 0) { + if (crc) *crc = m_crc; + if (size) *size = m_size; + found = 1; + break; + } + } + fclose(f); + return found; +} + +/* ============================================================ */ +/* 假解码回调(契约边界:logic 不链 SDK) */ +/* ============================================================ */ +static int g_dummy; +static int g_load_n; +static int g_unload_n; +static char g_load_path[MR_CACHE_FILE_MAX]; + +static meow_img *fake_load(const char *path, void *ud, int maxdim) +{ + (void)ud; + (void)maxdim; + g_load_n++; + snprintf(g_load_path, sizeof g_load_path, "%s", path ? path : ""); + return (meow_img *)&g_dummy; +} + +static void fake_unload(meow_img *img) +{ + if (img) g_unload_n++; +} + +static int archive_pages_probe(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + +static int count_cache_files(void) +{ + const char *dir = mr_cache_dir(); + if (!dir || !dir[0]) return -1; + DIR *d = opendir(dir); + if (!d) return -1; + int n = 0; + size_t plen = strlen(MR_CACHE_PREFIX); + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strncmp(de->d_name, MR_CACHE_PREFIX, plen) == 0) n++; + } + closedir(d); + return n; +} + +/* ============================================================ */ +/* T1 扩展名 */ +/* ============================================================ */ +static void test_ext(void) +{ + CHECK(mr_path_is_archive("a.cbz") == 1, "cbz"); + CHECK(mr_path_is_archive("A.CBZ") == 1, "CBZ"); + CHECK(mr_path_is_archive("x.zip") == 1, "zip"); + CHECK(mr_path_is_archive("X.ZiP") == 1, "ZiP"); + CHECK(mr_path_is_archive("a.cbr") == 0, "cbr not archive"); + CHECK(mr_path_is_archive("a.gz") == 0, "gz not archive"); + CHECK(mr_path_is_archive("cbz") == 0, "no extension"); + CHECK(mr_path_is_archive(".cbz") == 0, "dot name"); + CHECK(mr_path_is_archive(NULL) == 0, "NULL safe"); +} + +/* ============================================================ */ +/* T2 页枚举 / 排序 / 名字透传 */ +/* ============================================================ */ +static void test_enum(void) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_lib, "vol2.cbz"); + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL); + CHECK(n == 3, "vol2 pages=%d (want 3)", n); + CHECK(zb.count == 3, "zb.count=%d", zb.count); + if (n != 3) { + mr_zip_book_free(&zb); + return; + } + + char name[512]; + mr_zip_book_page_name(&zb, 0, name, sizeof name); + CHECK(strcmp(name, "ch01/p2.png") == 0, "page0=%s", name); + mr_zip_book_page_name(&zb, 1, name, sizeof name); + CHECK(strcmp(name, "ch01/p10.png") == 0, "page1=%s", name); + mr_zip_book_page_name(&zb, 2, name, sizeof name); + CHECK(strcmp(name, CJK_PAGE) == 0, "page2=%s", name); + + mr_zip_book_page_name(&zb, -1, name, sizeof name); + CHECK(name[0] == '\0', "page_name(-1) not empty"); + mr_zip_book_page_name(&zb, 3, name, sizeof name); + CHECK(name[0] == '\0', "page_name(count) not empty"); + mr_zip_book_page_name(&zb, 0, NULL, 10); + mr_zip_book_page_name(&zb, 0, name, 0); + + /* 目录项/文本/0 字节条目都被过滤(count==3 已证明)。 */ + + mr_book b; + memset(&b, 0, sizeof b); + mr_zip_book_bind(&b, NULL); + CHECK(!mr_book_valid(&b), "bind NULL must leave book invalid"); + CHECK(mr_book_count(&b) == 0 && mr_book_open(&b, 0) == NULL, "invalid book safe"); + + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_valid(&b), "valid after bind"); + CHECK(mr_book_count(&b) == 3, "book count=%d", mr_book_count(&b)); + CHECK(mr_book_open(&b, 3) == NULL, "open(count) != NULL"); + CHECK(mr_book_open(&b, -1) == NULL, "open(-1) != NULL"); + + int before = g_load_n; + meow_img *img = mr_book_open(&b, 0); + CHECK(img != NULL, "open(0) returned NULL"); + CHECK(g_load_n == before + 1, "load callback not called"); + CHECK(strncmp(g_load_path, mr_cache_dir(), strlen(mr_cache_dir())) == 0, + "load path not in cache dir: %s", g_load_path); + CHECK(access(g_load_path, R_OK) == 0, "extracted file missing: %s", g_load_path); + CHECK(strstr(g_load_path, ".png") != NULL, "extension not preserved: %s", g_load_path); + mr_book_close(&b, img); + CHECK(g_unload_n >= 1, "unload not called"); + mr_book_close(&b, NULL); /* 容忍 NULL */ + + mr_book_destroy(&b); /* 走 destroy→zip_free */ + CHECK(!mr_book_valid(&b), "destroy left book valid"); + mr_zip_book_free(&zb); /* 幂等 */ +} + +/* ============================================================ */ +/* T3 路径安全 + 解压内容 */ +/* ============================================================ */ +static void test_pathsafe(void) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_feat, "pathsafe.cbz"); + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL); + CHECK(n == 2, "pathsafe pages=%d (want 2: ok1.png, sub/ok2.jpg)", n); + if (n != 2) { + mr_zip_book_free(&zb); + return; + } + + char name[512]; + mr_zip_book_page_name(&zb, 0, name, sizeof name); + CHECK(strcmp(name, "ok1.png") == 0, "page0=%s", name); + mr_zip_book_page_name(&zb, 1, name, sizeof name); + CHECK(strcmp(name, "sub/ok2.jpg") == 0, "page1=%s", name); + + const char *bad[] = { "../evil.png", "/abs.png", "a/../../b.png", "sub/../ok3.png", + ".hidden/x.png", "._mac.png", "sub\\win.png" }; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) { + CHECK(mr_zip_entry_is_page(bad[i], (unsigned)strlen(bad[i])) == 0, + "unsafe entry accepted: %s", bad[i]); + } + + for (int i = 0; i < n; i++) { + char out[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&zb, i, out, (int)sizeof out) == 0, + "page_path(%d) failed", i); + if (!out[0]) continue; + CHECK(strncmp(out, mr_cache_dir(), strlen(mr_cache_dir())) == 0, + "cache path escaped dir: %s", out); + mr_zip_book_page_name(&zb, i, name, sizeof name); + unsigned long crc = 0; + long size = 0; + CHECK(manifest_lookup("features/pathsafe.cbz", name, &crc, &size), + "manifest miss %s", name); + unsigned long got_crc = 0; + long got_size = 0; + CHECK(file_crc(out, &got_crc, &got_size), "crc read failed: %s", out); + CHECK(got_crc == crc && got_size == size, + "extracted bytes differ for %s (crc %lx/%lx size %ld/%ld)", + name, got_crc, crc, got_size, size); + } + mr_zip_book_free(&zb); +} + +/* ============================================================ */ +/* T4 meta(原始像素尺寸) */ +/* ============================================================ */ +static void test_meta(void) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_lib, "vol2.cbz"); + + mr_zip_book zb; + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 3, + "vol2 init for meta"); + mr_book b; + mr_zip_book_bind(&b, &zb); + + int w = 0, h = 0; + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 41 && h == 31, + "meta0=%dx%d (want 41x31)", w, h); + CHECK(mr_book_meta(&b, 1, &w, &h) == 1 && w == 40 && h == 30, + "meta1=%dx%d (want 40x30)", w, h); + CHECK(mr_book_meta(&b, 2, &w, &h) == 1 && w == 12 && h == 34, + "meta2=%dx%d (want 12x34)", w, h); + CHECK(mr_book_meta(&b, 3, &w, &h) == 0 && w == 0 && h == 0, "meta(count)"); + mr_book_destroy(&b); + + joinp(path, sizeof path, g_lib, "Vol10.cbz"); + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1, + "Vol10 init"); + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 7 && h == 9, + "bmp meta=%dx%d (want 7x9)", w, h); + mr_book_destroy(&b); + + joinp(path, sizeof path, g_lib, "box/box2.cbz"); + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1, + "box2 init"); + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 12 && h == 34, + "jpeg meta=%dx%d (want 12x34)", w, h); + mr_book_destroy(&b); + + /* 内存探测边界 */ + CHECK(mr_probe_size_mem(NULL, 100, &w, &h) == 0, "probe NULL"); + CHECK(mr_probe_size_mem("ABCDEFGH", 8, &w, &h) == 0, "probe junk"); + + /* meta 只解压到内存读头,不落解压缓存(box1 从未被 open 过)。 */ + joinp(path, sizeof path, g_lib, "box/box1.cbz"); + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 2, + "box1 init"); + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 8 && h == 8, + "box1 meta=%dx%d (want 8x8)", w, h); + struct stat st; + char pname[256], cpath[MR_CACHE_FILE_MAX]; + CHECK(stat(path, &st) == 0, "stat box1"); + mr_zip_book_page_name(&zb, 0, pname, (int)sizeof pname); + CHECK(mr_cache_path(path, (long long)st.st_mtime, (long long)st.st_size, + pname, 0, cpath, (int)sizeof cpath) == 0, "meta cache path"); + CHECK(access(cpath, F_OK) != 0, "meta wrote the page cache"); + mr_book_destroy(&b); +} + +/* ============================================================ */ +/* T5 解压缓存纪律 */ +/* ============================================================ */ +static void test_cache(void) +{ + char path[PATH_CAP]; + char data_dir[PATH_CAP]; + char cache_copy[MR_CACHE_DIR_MAX]; + joinp(path, sizeof path, g_feat, "big.cbz"); + + /* ---- (a) 64 页 LRU ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picobrowser/data"); + CHECK(mr_cache_init(data_dir) == 0, "cache init c1"); + snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); + CHECK(cache_copy[0] && strstr(cache_copy, "/cache") != NULL, + "cache dir=%s", cache_copy); + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL); + CHECK(n == 80, "big pages=%d (want 80)", n); + char p[MR_CACHE_FILE_MAX]; + for (int i = 0; i < n; i++) + CHECK(mr_zip_book_page_path(&zb, i, p, (int)sizeof p) == 0, + "big page %d path", i); + CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, + "cache count=%d (want 64)", mr_cache_count()); + CHECK(mr_cache_bytes() <= MR_CACHE_MAX_BYTES, "cache bytes over cap"); + CHECK(count_cache_files() == mr_cache_count(), + "disk files=%d index=%d", count_cache_files(), mr_cache_count()); + + /* 最旧的 16 页被淘汰,最新的仍在 */ + struct stat st; + char arc[PATH_CAP]; + char name[512]; + snprintf(arc, sizeof arc, "%s", path); + CHECK(stat(arc, &st) == 0, "stat big.cbz"); + char p0[MR_CACHE_FILE_MAX], p79[MR_CACHE_FILE_MAX]; + mr_zip_book_page_name(&zb, 0, name, sizeof name); + CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size, + name, 0, p0, (int)sizeof p0) == 0, "cache_path p0"); + mr_zip_book_page_name(&zb, 79, name, sizeof name); + CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size, + name, 79, p79, (int)sizeof p79) == 0, "cache_path p79"); + CHECK(access(p0, F_OK) != 0, "LRU kept the oldest page"); + CHECK(access(p79, F_OK) == 0, "LRU dropped the newest page"); + + /* 命中不重写:同一 inode */ + struct stat s1, s2; + CHECK(stat(p79, &s1) == 0, "stat p79"); + CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 re-get"); + CHECK(stat(p, &s2) == 0 && s1.st_ino == s2.st_ino, "cache hit rewrote the file"); + mr_zip_book_free(&zb); + + /* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picobrowser/data"); + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache init c2"); + snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); + + char orphan[MR_CACHE_FILE_MAX], keep[MR_CACHE_FILE_MAX]; + snprintf(orphan, sizeof orphan, "%s/%sdeadbeef.png", cache_copy, MR_CACHE_PREFIX); + snprintf(keep, sizeof keep, "%s/thumb.bmp", cache_copy); + FILE *f = fopen(orphan, "wb"); + CHECK(f != NULL, "create orphan"); + if (f) { fputs("orphan", f); fclose(f); } + f = fopen(keep, "wb"); + CHECK(f != NULL, "create thumb"); + if (f) { fputs("thumb", f); fclose(f); } + + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache re-init c2"); + CHECK(access(orphan, F_OK) != 0, "startup did not clean orphan mrpage_*"); + CHECK(access(keep, F_OK) == 0, "startup deleted non-cache file"); + + size_t big_len = 20u * 1024u * 1024u; + unsigned char *big = (unsigned char *)malloc(big_len); + CHECK(big != NULL, "malloc 20MiB"); + if (big) { + memset(big, 0xA5, big_len); + char b1[MR_CACHE_FILE_MAX], b2[MR_CACHE_FILE_MAX]; + snprintf(b1, sizeof b1, "%s/%sbig1.img", cache_copy, MR_CACHE_PREFIX); + snprintf(b2, sizeof b2, "%s/%sbig2.img", cache_copy, MR_CACHE_PREFIX); + mr_cache_shutdown(); + mr_cache_init(data_dir); + CHECK(mr_cache_store(b1, big, big_len) == 1, "store 20MiB #1"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len, + "count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes()); + CHECK(mr_cache_store(b2, big, big_len) == 1, "store 20MiB #2"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len, + "byte cap: count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes()); + CHECK(access(b1, F_OK) != 0, "byte cap did not evict the oldest"); + free(big); + } + unlink(keep); + + /* ---- (c) 缓存不可用时优雅失败(不崩、不写文件) ---- */ + mr_cache_shutdown(); + CHECK(mr_cache_ready() == 0, "shutdown left cache ready"); + joinp(path, sizeof path, g_lib, "vol2.cbz"); + mr_zip_book zb2; + CHECK(mr_zip_book_init(&zb2, path, 720, fake_load, fake_unload, NULL) == 3, + "init without cache"); + CHECK(mr_zip_book_page_path(&zb2, 0, p, (int)sizeof p) == -1, + "page_path without cache should fail"); + int before = g_load_n; + mr_book b; + mr_zip_book_bind(&b, &zb2); + CHECK(mr_book_open(&b, 0) == NULL, "open without cache should fail"); + CHECK(g_load_n == before, "decode callback called without cache"); + mr_book_destroy(&b); + + joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picobrowser/data"); + CHECK(mr_cache_init(data_dir) == 0, "cache init app"); +} + +/* ============================================================ */ +/* T6 坏档容错 */ +/* ============================================================ */ +static void expect_bad(const char *path, const char *tag) +{ + mr_book_handle h; + mr_book b; + int n = mr_book_open_auto(&b, &h, path, 720, fake_load, fake_unload, NULL); + CHECK(n == -1, "%s: open_auto=%d (want -1)", tag, n); + CHECK(!mr_book_valid(&b), "%s: book valid after failure", tag); + CHECK(mr_book_open(&b, 0) == NULL, "%s: open after failure", tag); + mr_book_close_auto(&b, &h); + mr_book_close_auto(&b, &h); /* 幂等 */ + CHECK(mr_zip_count_pages(path) == 0, "%s: count_pages != 0", tag); +} + +static void test_bad(void) +{ + char p[PATH_CAP]; + + joinp(p, sizeof p, g_lib, "broken.cbz"); + expect_bad(p, "broken"); + joinp(p, sizeof p, g_lib, "truncated.cbz"); + expect_bad(p, "truncated"); + joinp(p, sizeof p, g_feat, "truncated.cbz"); + expect_bad(p, "truncated(feat)"); + + joinp(p, sizeof p, g_lib, "missing.cbz"); + expect_bad(p, "missing"); + + mr_book_handle h; + mr_book b; + CHECK(mr_book_open_auto(&b, &h, NULL, 720, fake_load, fake_unload, NULL) == -1, + "open_auto(NULL)"); + CHECK(mr_book_open_auto(NULL, &h, p, 720, fake_load, fake_unload, NULL) == -1, + "open_auto(NULL book)"); + CHECK(mr_book_open_auto(&b, NULL, p, 720, fake_load, fake_unload, NULL) == -1, + "open_auto(NULL handle)"); + + /* 合法 zip 但没有图片页 / 声明尺寸超上限(zip 炸弹) */ + const char *zero[] = { "notabook.zip", "emptypages.cbz" }; + for (size_t i = 0; i < sizeof zero / sizeof zero[0]; i++) { + joinp(p, sizeof p, g_lib, zero[i]); + int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 0, "%s: open_auto=%d (want 0)", zero[i], n); + CHECK(!mr_book_valid(&b), "%s: book bound with 0 pages", zero[i]); + CHECK(mr_book_open(&b, 0) == NULL, "%s: open on empty book", zero[i]); + mr_book_close_auto(&b, &h); + CHECK(mr_zip_count_pages(p) == 0, "%s: count_pages != 0", zero[i]); + } + + joinp(p, sizeof p, g_feat, "bomb.cbz"); + CHECK(mr_zip_count_pages(p) == 0, "bomb.cbz should have no page (96MiB)"); + CHECK(mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL) == 0, + "bomb: open_auto should be 0 pages"); + mr_book_close_auto(&b, &h); +} + +/* ============================================================ */ +/* T7 统一开册契约 */ +/* ============================================================ */ +static void test_open_auto(void) +{ + char p[PATH_CAP]; + joinp(p, sizeof p, g_lib, "bookA"); + + mr_book_handle h; + mr_book b; + int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 2 && h.kind == MR_BOOK_KIND_DIR, "dir open n=%d kind=%d", n, h.kind); + CHECK(mr_book_valid(&b) && mr_book_count(&b) == 2, "dir book contract"); + int before = g_load_n; + meow_img *img = mr_book_open(&b, 1); + CHECK(img != NULL && g_load_n == before + 1, "dir open(1)"); + CHECK(strstr(g_load_path, "002.png") != NULL, "dir page path=%s", g_load_path); + mr_book_close(&b, img); + int mw = 0, mh = 0; + CHECK(mr_book_meta(&b, 0, &mw, &mh) == 1 && mw == 30 && mh == 20, + "dir meta=%dx%d (want 30x20)", mw, mh); + mr_book_close_auto(&b, &h); + CHECK(h.kind == MR_BOOK_KIND_NONE && !mr_book_valid(&b), "dir close_auto"); + mr_book_close_auto(&b, &h); /* 幂等 */ + + joinp(p, sizeof p, g_lib, "vol2.cbz"); + n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 3 && h.kind == MR_BOOK_KIND_ZIP, "zip open n=%d kind=%d", n, h.kind); + CHECK(mr_book_valid(&b) && mr_book_count(&b) == 3, "zip book contract"); + CHECK(mr_book_open(&b, 99) == NULL, "zip out of range"); + mr_book_close_auto(&b, &h); + CHECK(!mr_book_valid(&b), "zip close_auto"); + + joinp(p, sizeof p, g_lib, "emptypages.cbz"); + n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 0 && h.kind == MR_BOOK_KIND_ZIP, "empty zip n=%d kind=%d", n, h.kind); + CHECK(!mr_book_valid(&b), "empty zip bound"); + mr_book_close_auto(&b, &h); +} + +/* ============================================================ */ +/* T8 书库列档 */ +/* ============================================================ */ +static const mr_library_entry *lib_find(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + return NULL; +} + +static void test_library(void) +{ + mr_test_cfg_clear(); + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + CHECK(n == 4, "library rows=%d (want bookA, box, vol2.cbz, Vol10.cbz)", n); + if (n == 4) { + CHECK(strcmp(lib.items[0].name, "bookA") == 0, "row0=%s", lib.items[0].name); + CHECK(strcmp(lib.items[1].name, "box") == 0, "row1=%s", lib.items[1].name); + CHECK(strcmp(lib.items[2].name, "vol2.cbz") == 0, "row2=%s", lib.items[2].name); + CHECK(strcmp(lib.items[3].name, "Vol10.cbz") == 0, "row3=%s", lib.items[3].name); + } + + const mr_library_entry *e = lib_find(&lib, "bookA"); + CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields"); + CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s", + e ? e->cover : ""); + + e = lib_find(&lib, "box"); + CHECK(e && e->has_images == 0 && e->subdirs == 0, "box fields"); + CHECK(e && strcmp(e->subtitle, "2 books") == 0, "box subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && e->cover[0] == '\0', "box cover should be empty"); + + e = lib_find(&lib, "vol2.cbz"); + CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0, + "vol2.cbz fields"); + CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)"); + CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s", + e ? e->path : ""); + + e = lib_find(&lib, "Vol10.cbz"); + CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0, + "Vol10.cbz pages/subtitle"); + + CHECK(lib_find(&lib, "notabook.zip") == NULL, "text-only zip listed"); + CHECK(lib_find(&lib, "emptypages.cbz") == NULL, "no-image cbz listed"); + CHECK(lib_find(&lib, "broken.cbz") == NULL, "broken cbz listed"); + CHECK(lib_find(&lib, "truncated.cbz") == NULL, "truncated cbz listed"); + CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz listed"); + mr_library_free(&lib); + + /* 不注入探测回调:与 M7 完全一致(档案册不参与,只含档案册的目录也不 nav)。 */ + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 1, + "legacy scan rows=%d (want 1: bookA)", lib.count); + CHECK(lib.count >= 1 && strcmp(lib.items[0].name, "bookA") == 0, + "legacy scan row0"); + mr_library_free(&lib); +} + +/* ============================================================ */ +/* T9 册间衔接 */ +/* ============================================================ */ +static void expect_adj(const char *from, int delta, const char *want, const char *tag) +{ + char out[MR_PATH_MAX]; + int r = mr_book_adjacent_ex(from, delta, out, (int)sizeof out, + archive_pages_probe, NULL); + if (!want) { + CHECK(r == 0 && out[0] == '\0', "%s: r=%d out=%s", tag, r, out); + } else { + CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want); + } +} + +static void test_adjacent(void) +{ + char bookA[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX]; + char broken[MR_PATH_MAX]; + joinp(bookA, sizeof bookA, g_lib, "bookA"); + joinp(vol2, sizeof vol2, g_lib, "vol2.cbz"); + joinp(vol10, sizeof vol10, g_lib, "Vol10.cbz"); + joinp(broken, sizeof broken, g_lib, "broken.cbz"); + + /* 兄弟表 = bookA + vol2.cbz + Vol10.cbz(box 无直接图、坏档不算书)。 */ + expect_adj(bookA, 1, vol2, "bookA next"); + expect_adj(vol2, 1, vol10, "vol2 next"); + expect_adj(vol10, 1, NULL, "vol10 next (end)"); + expect_adj(bookA, -1, NULL, "bookA prev (start)"); + expect_adj(vol2, -1, bookA, "vol2 prev"); + expect_adj(vol10, -1, vol2, "vol10 prev"); + expect_adj(broken, 1, NULL, "broken not a book"); + expect_adj(broken, -1, NULL, "broken not a book prev"); + + /* 不注入探测回调:档案册不参与(M7 语义) */ + char out[MR_PATH_MAX]; + CHECK(mr_book_adjacent(vol2, 1, out, (int)sizeof out) == 0, + "legacy adjacency should ignore archives (r=%d)", 0); + CHECK(mr_book_adjacent(bookA, 1, out, (int)sizeof out) == 0, + "legacy adjacency bookA next should be none"); + + /* 参数边界 */ + CHECK(mr_book_adjacent_ex(vol2, 0, out, (int)sizeof out, archive_pages_probe, + NULL) == -1, "delta 0"); + CHECK(mr_book_adjacent_ex(NULL, 1, out, (int)sizeof out, archive_pages_probe, + NULL) == -1, "NULL dir"); + CHECK(mr_book_adjacent_ex(vol2, 1, NULL, 32, archive_pages_probe, NULL) == -1, + "NULL out"); + CHECK(mr_book_adjacent_ex("/", 1, out, (int)sizeof out, archive_pages_probe, + NULL) == -1, "root"); +} + +/* ============================================================ */ +/* T10 续读角标 + 封面 */ +/* ============================================================ */ +static void test_badge_cover(void) +{ + char vol2[MR_PATH_MAX]; + joinp(vol2, sizeof vol2, g_lib, "vol2.cbz"); + + mr_test_cfg_clear(); + mr_cont_save(vol2, 2, MR_MODE_WEBTOON); + + mr_library lib; + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, + NULL) == 4, "rescan for badge"); + const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); + CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s", + e ? e->subtitle : ""); + e = lib_find(&lib, "Vol10.cbz"); + CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge"); + mr_library_free(&lib); + + /* 清除记录后角标消失 */ + mr_cont_save(vol2, 0, MR_MODE_WEBTOON); + memset(&lib, 0, sizeof lib); + mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + e = lib_find(&lib, "vol2.cbz"); + CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone"); + mr_library_free(&lib); + + /* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */ + char cover[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_cover_path(vol2, cover, (int)sizeof cover) == 1, "cover path"); + CHECK(cover[0] && access(cover, R_OK) == 0, "cover file missing: %s", cover); + unsigned long crc = 0; + long size = 0; + CHECK(file_crc(cover, &crc, &size), "cover crc"); + unsigned long want_crc = 0; + long want_size = 0; + CHECK(manifest_lookup("library/vol2.cbz", "ch01/p2.png", &want_crc, &want_size), + "cover manifest"); + CHECK(crc == want_crc && size == want_size, "cover bytes differ"); + + char broken[MR_PATH_MAX]; + joinp(broken, sizeof broken, g_lib, "broken.cbz"); + CHECK(mr_zip_cover_path(broken, cover, (int)sizeof cover) == 0, "broken cover"); + CHECK(mr_zip_cover_path(vol2, NULL, 16) == 0, "cover NULL out"); +} + +/* ============================================================ */ +int main(int argc, char **argv) +{ + g_argv0 = argv[0]; + char gen_buf[PATH_CAP]; + const char *gen = argc > 1 ? argv[1] : find_generator(gen_buf, sizeof gen_buf); + + if (!make_temp_dir()) { + perror("mkdtemp"); + return 2; + } + joinp(g_lib, sizeof g_lib, g_root, "library"); + joinp(g_feat, sizeof g_feat, g_root, "features"); + joinp(g_data, sizeof g_data, g_root, "app/os.meow.picobrowser/data"); + + if (!run_generator(gen)) { + printf("generator failed: %s\n", gen); + rm_rf(g_root); + return 2; + } + if (mr_cache_init(g_data) != 0) { + printf("cache init failed: %s\n", g_data); + rm_rf(g_root); + return 2; + } + + test_ext(); + test_enum(); + test_pathsafe(); + test_meta(); + test_cache(); + test_bad(); + test_open_auto(); + test_library(); + test_adjacent(); + test_badge_cover(); + + mr_cache_shutdown(); + rm_rf(g_root); + + printf("cbz tests: %d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 50b69e4..a202aed 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,21 +1,24 @@ /* ui/library/library.c — 图库根页(ACT_LIBRARY): * * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 - * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册)。 - * 行=缩略图 + 目录名 + 页数;目录由 logic/library 扫描并自然排序。 + * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册); + * M11 起 .cbz/.zip 也作为「册」与文件夹并列(行=封面+文件名+页数,点入直接阅读)。 + * 行数据由 logic/library 扫描并自然排序;档案册页数/首图由 logic/reader 的 zip 页源提供。 * * 流畅度(用户指令优先): * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, * 完成后在 on_tick 补位并只 invalidate 列表视口; * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData//cache - * (BMP 原始缩略图,文件名含源文件 mtime+size,源文件变化自动失效;≥64 张按 - * mtime LRU 删除); + * (BMP 原始缩略图,文件名含源 mtime+size,源文件变化自动失效;≥64 张按 + * mtime LRU 删除);档案册封面=档内首图,先解压到同一 cache(page_cache 管 LRU); * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 * * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 */ #include "tuku_internal.h" +#include "logic/reader/page_source.h" +#include "logic/reader/page_cache.h" #include #include @@ -106,20 +109,10 @@ static void cache_init(void) const char *data = meow_config_dir(); if (!data || !data[0]) return; - int n = snprintf(g_cache_dir, sizeof g_cache_dir, "%s", data); - if (n <= 0 || n >= (int)sizeof g_cache_dir) { g_cache_dir[0] = '\0'; return; } - - char *slash = strrchr(g_cache_dir, '/'); - if (slash && strcmp(slash, "/data") == 0) *slash = '\0'; - - size_t len = strlen(g_cache_dir); - n = snprintf(g_cache_dir + len, sizeof g_cache_dir - len, "/cache"); - if (n <= 0 || n >= (int)(sizeof g_cache_dir - len)) { g_cache_dir[0] = '\0'; return; } - - if (mkdir(g_cache_dir, 0755) != 0 && errno != EEXIST) { - g_cache_dir[0] = '\0'; - return; - } + /* 缩略图缓存与解压页缓存同目录(MeowData//cache)。目录推导、建立与 + * 启动清孤儿统一交给 logic/reader/page_cache,避免两处目录规则分叉。 */ + if (mr_cache_init(data) != 0 || mr_cache_dir()[0] == '\0') return; + snprintf(g_cache_dir, sizeof g_cache_dir, "%s", mr_cache_dir()); g_cache_on = 1; } @@ -220,12 +213,21 @@ static int write_bmp(const char *path, const meow_img *im) return ok ? 0 : -1; } -/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 */ +/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 + * 档案册(M11)的 cover 是 .cbz/.zip 路径:先解压档内首图到 page_cache, + * 再按普通图片路径解码;缩略图缓存键仍用档案本身(mtime/size 变化即失效)。 */ static meow_img *thumb_load(const char *cover) { struct stat st; if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; + char extracted[MR_CACHE_FILE_MAX]; + const char *src = cover; + if (mr_path_is_archive(cover)) { + if (!mr_zip_cover_path(cover, extracted, (int)sizeof extracted)) return NULL; + src = extracted; + } + char cp[MR_PATH_MAX + 96]; if (g_cache_on && g_cache_dir[0]) { cache_path(cp, (int)sizeof cp, cover, &st); @@ -239,7 +241,7 @@ static meow_img *thumb_load(const char *cover) } } - meow_img *im = meow_img_load_file(cover); + meow_img *im = meow_img_load_file(src); if (!im) return NULL; meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); if (small) { meow_img_free(im); im = small; } @@ -380,7 +382,11 @@ static void lib_bind(int idx, meow_lv_item *item, void *ctx) item->icon = im; } else { item->icon_kind = e->has_images ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; - if (e->cover[0]) thumb_enqueue(idx, e->cover); + /* 文件夹册用缩略图源图;档案册没有图片文件,用档案路径(worker 解压首图)。 */ + const char *src = NULL; + if (e->cover[0]) src = e->cover; + else if (e->has_images && mr_path_is_archive(e->path)) src = e->path; + if (src) thumb_enqueue(idx, src); } } @@ -411,7 +417,7 @@ static void list_rebuild(void) /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ meow_listview_set_container(g_lv, 1); - meow_listview_set_empty_text(g_lv, "No folders with PNG/JPEG/BMP"); + meow_listview_set_empty_text(g_lv, "No folders or CBZ books"); g_scroll = meow_scroll_wrap(g_lv, 1); if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); @@ -446,12 +452,20 @@ static void reset_async(void) pthread_mutex_unlock(&g_lock); } +/* 书库扫描的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ +static int cbz_archive_pages(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + static void load_dir(const char *dir) { snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); reset_async(); thumbs_free(); - if (mr_library_scan(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH) < 0) { + if (mr_library_scan_ex(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH, + cbz_archive_pages, NULL) < 0) { mr_library_free(&g_lib); meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir); } diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index ae80fe2..a00aea2 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -39,6 +39,7 @@ #include "tuku_internal.h" #include "ui/reader/reader.h" #include "logic/reader/page_source.h" +#include "logic/reader/page_cache.h" #include "logic/reader/nav.h" #include @@ -74,7 +75,7 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; -static mr_dir_book g_db; +static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; static int g_count; /* 0=空册/不可读 */ static int g_open_err; @@ -765,7 +766,7 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) int w = g_vw, h = g_vh; if (g_open_err) { - meow_ui_text_center(w / 2, h / 2 - 20, "无法打开目录", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, h / 2 - 20, "无法打开此册", th->font_title, th->onSurface); meow_ui_text_center(w / 2, h / 2 + 24, g_dir, th->font_body, th->onSurfaceVariant); return; } @@ -1152,8 +1153,7 @@ static void open_book_reset_state(void) mr_strip_free(&g_strip); g_strip_ok = 0; slots_free_all(); - mr_book_destroy(&g_book); - mr_dir_book_free(&g_db); + mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } g_gen++; /* 作废旧册的异步结果 */ g_count = 0; @@ -1183,10 +1183,11 @@ static int open_book(const char *dir, int start_page, int mode) g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; int maxdim = g_vw > g_vh ? g_vw : g_vh; - int n = mr_dir_book_init(&g_db, g_dir, maxdim, - reader_load_page, reader_unload_page, NULL); + /* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约, + * 下面的翻页/条漫消费代码完全不区分 provider。 */ + int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim, + reader_load_page, reader_unload_page, NULL); if (n < 0) { - mr_dir_book_bind(&g_book, NULL); g_open_err = 1; worker_start(); update_title(); @@ -1195,7 +1196,6 @@ static int open_book(const char *dir, int start_page, int mode) return 0; } g_count = n; - mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); int page = start_page < 0 ? g_count - 1 : start_page; page = mr_nav_clamp_page(page, g_count); @@ -1233,12 +1233,20 @@ static int open_book(const char *dir, int start_page, int mode) return 1; } +/* 册间衔接的档案册探测(M11):兄弟表 = 含图目录 ∪ 含图片页的 .cbz/.zip。 */ +static int cbz_pages_probe(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + static int book_switch(int delta) { if (delta == 0) return 0; if (g_count <= 0) return 0; char next[MR_PATH_MAX]; - int r = mr_book_adjacent(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next); + int r = mr_book_adjacent_ex(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next, + cbz_pages_probe, NULL); if (r != 1) { toast(delta > 0 ? "已是最后一册" : "已是第一册"); return 0; @@ -1770,6 +1778,13 @@ static void reader_page_enter(meow_view *v, void *ctx) } if (g_tb) meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOP_BAR_H); + /* 档案册解压缓存(M11):MeowData//cache。library 页通常已初始化, + * 这里幂等兜底(进程重启后直接进阅读器、或交付环境只剩 reader 入口)。 */ + { + const char *data = meow_config_dir(); + if (data && data[0]) mr_cache_init(data); + } + /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); int page = mr_cont_get_page(g_dir, 0); @@ -1801,8 +1816,7 @@ static void reader_page_leave(meow_view *v, void *ctx) mr_strip_free(&g_strip); g_strip_ok = 0; slots_free_all(); - mr_book_destroy(&g_book); - mr_dir_book_free(&g_db); + mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } From c775f8558ce9f57e9b371db7187390757f95333a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 12:13:47 +0800 Subject: [PATCH 11/38] =?UTF-8?q?fix(tuku):=20=E8=AF=84=E5=AE=A1=20P2-1/P2?= =?UTF-8?q?-2=E2=80=94=E2=80=94=E8=A7=A3=E5=8E=8B=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E7=BC=93=E5=AD=98=E4=BA=92=E5=88=A0?= =?UTF-8?q?=E8=87=AA=E6=84=88=20+=20=E5=A4=A7=E9=A1=B5=E6=BA=A2=E5=87=BA?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(必修):解压页缓存与缩略图 LRU 在 *.bmp 上相互踩踏 - ui/library 的 cache_prune 先按 page_cache 的共用判据 mr_cache_is_page_name 跳过 mrpage_*,不再把 BMP 解压页算进缩略图额度或 unlink(含 Vol10.cbz 这类 BMP 档不再出现「被缩略图 LRU 删掉 → 首页解码失败」); - mr_cache_touch 命中时 stat 校验文件存在与大小,缺失/尺寸不符即移出索引并按 未命中返回,调用方重新解压——外部清理/掉电残留/误删都能自愈,不再返回死路径。 P2-2(文档化一次性溢出): - page_cache.h 与 make_room_locked 写明:单条目本身超过 32MiB 时只保留该条、 其它全部淘汰,占用上限受页源单页 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束; 大页仍可读,不因预算拒绝落缓存。 - 新增 33MiB 页夹具(PNG 头 + 可压零填充,deflate 后仅数十 KB)与断言: 大页可读、cache_bytes 允许超 32MiB、小页被淘汰、再来小页时大页按预算淘汰。 顺手:page_cache.h 注释包名 os.meow.picobrowser → os.meow.picbrowser(测试路径同步)。 测试:test_cbz 267 → 296 项全 PASS;run_tests.sh(zip 106)与 run_webtoon_tests.sh 全绿;交叉构建 sdk/tuku 零警告,产物静态 MIPS32r2 ELF。 --- tuku/logic/reader/page_cache.c | 24 ++++++++-- tuku/logic/reader/page_cache.h | 14 ++++-- tuku/test/make_cbz_samples.py | 11 +++++ tuku/test/test_cbz.c | 85 +++++++++++++++++++++++++++++++--- tuku/ui/library/library.c | 4 ++ 5 files changed, 125 insertions(+), 13 deletions(-) diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c index 7075398..4cf403b 100644 --- a/tuku/logic/reader/page_cache.c +++ b/tuku/logic/reader/page_cache.c @@ -57,6 +57,11 @@ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz) return 0; } +int mr_cache_is_page_name(const char *name) +{ + return name && strncmp(name, MR_CACHE_PREFIX, strlen(MR_CACHE_PREFIX)) == 0; +} + const char *mr_cache_dir(void) { return g_ready ? g_dir : ""; @@ -95,6 +100,9 @@ static void idx_evict_locked(void) idx_remove_locked(oldest); } +/* 腾出 len 字节:页数或字节先到上限就淘汰最旧。单条 len 本身超过 + * MR_CACHE_MAX_BYTES 时会清空其它条目、让这一条独占(文档化的一次性溢出, + * 上限由页源单页 64MiB 约束)——大页仍可读,不因预算拒绝落缓存。 */ static void make_room_locked(long long len) { while (g_n > 0 && (g_n >= MR_CACHE_MAX_PAGES || @@ -275,19 +283,27 @@ int mr_cache_touch(const char *path) return 0; } int i = idx_find_locked(path); + /* 命中也要 stat 校验(P2-1 纵深防御):外部清理、掉电残留、或别的 LRU 误删 + * 都会让索引指向不存在的文件;假命中会让页源返回死路径且永不自愈。缺失或 + * 尺寸不符就从索引移除、按未命中处理,让调用方重新解压。 */ + struct stat st; + int exists = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; if (i >= 0) { + if (!exists || (long long)st.st_size != g_idx[i].size) { + idx_remove_locked(i); + pthread_mutex_unlock(&g_lock); + return 0; + } g_tick++; g_idx[i].used = g_tick; pthread_mutex_unlock(&g_lock); return 1; } /* 索引里没有(外部写入/索引满):磁盘上有就算命中,能装进索引就装。 */ - struct stat st; - int ok = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; - if (ok && g_n < MR_CACHE_MAX_PAGES && strlen(path) < MR_CACHE_FILE_MAX) + if (exists && g_n < MR_CACHE_MAX_PAGES && strlen(path) < MR_CACHE_FILE_MAX) idx_add_locked(path, (long long)st.st_size); pthread_mutex_unlock(&g_lock); - return ok ? 1 : 0; + return exists ? 1 : 0; } int mr_cache_store(const char *path, const void *data, size_t len) diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h index 51df42e..e0c4bda 100644 --- a/tuku/logic/reader/page_cache.h +++ b/tuku/logic/reader/page_cache.h @@ -5,10 +5,14 @@ * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 * * 目录与上限(纪律): - * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picobrowser/data, + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data, * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); - * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页); + * - **单条目溢出**:某页解压后本身就超过 32MiB 时允许一次性溢出——只保留该条、 + * 其它条目全部淘汰,占用上限受页源单页上限 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束。 + * 这样超大页仍可阅读,不会因为预算拒绝落缓存而变成永久空白页; + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页) + * (注意:缩略图 LRU 也必须在同一目录里跳过 mrpage_*,否则会互删); * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 * * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 @@ -34,10 +38,14 @@ extern "C" { #define MR_CACHE_FILE_MAX 256 /* 数据目录 → 缓存目录(纯字符串):末尾的 "data" 段替换为 "cache"。 - * 例:/…/MeowData/os.meow.picobrowser/data → /…/MeowData/os.meow.picobrowser/cache。 + * 例:/…/MeowData/os.meow.picbrowser/data → /…/MeowData/os.meow.picbrowser/cache。 * 成功 0;参数非法/缓冲不足 -1。 */ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); +/* 文件名是否属于解压页缓存(mrpage_ 前缀)。缩略图 LRU 与启动清孤儿共用这一判据, + * 保证两套缓存同目录时互不误删(P2-1)。1=解压页缓存。 */ +int mr_cache_is_page_name(const char *name); + /* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 * 同一目录重复调用为无操作(不会删掉正在用的页)。 */ int mr_cache_init(const char *data_dir); diff --git a/tuku/test/make_cbz_samples.py b/tuku/test/make_cbz_samples.py index 10ea21a..90f467e 100755 --- a/tuku/test/make_cbz_samples.py +++ b/tuku/test/make_cbz_samples.py @@ -32,6 +32,7 @@ import zipfile import zlib DATE = (2024, 1, 1, 0, 0, 0) +HUGE_PAGE_LEN = 33 * 1024 * 1024 # > 32MiB 预算:锁定「单条目一次性溢出」行为 # ---------------------------------------------------------------- 图片构造 @@ -190,6 +191,14 @@ def build_features(ro): with open(os.path.join(feat, "truncated.cbz"), "wb") as f: f.write(raw[:len(raw) // 2]) + # 单页 33MiB(PNG 头 + 可压零填充,deflate 后只有几十 KB): + # 页过滤放行(< 64MiB),解压后落缓存时应允许一次性超过 32MiB 预算。 + head = png_bytes(64, 64, 31) + payload = head + b"\x00" * (HUGE_PAGE_LEN - len(head)) + write_zip(os.path.join(feat, "hugepage.cbz"), [ + ("huge.png", payload, zipfile.ZIP_DEFLATED), + ]) + def write_manifest(ro): """每档案每条目:<相对路径>\t<条目名>\t\t(Tab 分隔,UTF-8 原名)。""" @@ -198,6 +207,8 @@ def write_manifest(ro): for name in sorted(files): if not name.lower().endswith((".cbz", ".zip")): continue + if name == "hugepage.cbz": + continue # 33MiB 页只为缓存溢出测试,manifest 不校验它 path = os.path.join(base, name) rel = os.path.relpath(path, ro) try: diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c index 257d682..4e4b29f 100644 --- a/tuku/test/test_cbz.c +++ b/tuku/test/test_cbz.c @@ -48,13 +48,14 @@ #define PATH_CAP 1024 #define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */ +#define HUGE_PAGE_LEN (33 * 1024 * 1024) /* 与 make_cbz_samples.py 的 HUGE_PAGE_LEN 一致 */ static int g_pass; static int g_fail; static char g_root[PATH_CAP]; /* 夹具根 */ static char g_lib[PATH_CAP]; /* /library */ static char g_feat[PATH_CAP]; /* /features */ -static char g_data[PATH_CAP]; /* /app/os.meow.picobrowser/data */ +static char g_data[PATH_CAP]; /* /app/os.meow.picbrowser/data */ static const char *g_argv0; #define CHECK(cond, ...) do { \ @@ -388,6 +389,13 @@ static void test_meta(void) mr_zip_book_bind(&b, &zb); CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 7 && h == 9, "bmp meta=%dx%d (want 7x9)", w, h); + /* P2-1:BMP 页的缓存文件名也是 *.bmp,但必须能被缩略图 LRU 判据识别为解压页 */ + char vp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&zb, 0, vp, (int)sizeof vp) == 0, "bmp page path"); + const char *vbase = strrchr(vp, '/'); + CHECK(vbase && mr_cache_is_page_name(vbase + 1) == 1, + "bmp page cache looks like a thumbnail: %s", vp); + CHECK(vbase && strstr(vbase, ".bmp") != NULL, "bmp page lost its extension: %s", vp); mr_book_destroy(&b); joinp(path, sizeof path, g_lib, "box/box2.cbz"); @@ -427,10 +435,19 @@ static void test_cache(void) char path[PATH_CAP]; char data_dir[PATH_CAP]; char cache_copy[MR_CACHE_DIR_MAX]; + + /* 解压页缓存与缩略图的文件名判据(P2-1):缩略图 LRU 依赖它跳过 mrpage_*。 */ + CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.bmp") == 1, "page name bmp"); + CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.png") == 1, "page name png"); + CHECK(mr_cache_is_page_name("0123456789abcdef_1700000000_1234.bmp") == 0, "thumb name"); + CHECK(mr_cache_is_page_name("thumb.bmp") == 0, "plain thumb"); + CHECK(mr_cache_is_page_name("") == 0 && mr_cache_is_page_name(NULL) == 0, + "page name edges"); + joinp(path, sizeof path, g_feat, "big.cbz"); /* ---- (a) 64 页 LRU ---- */ - joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picobrowser/data"); + joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picbrowser/data"); CHECK(mr_cache_init(data_dir) == 0, "cache init c1"); snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); CHECK(cache_copy[0] && strstr(cache_copy, "/cache") != NULL, @@ -470,10 +487,18 @@ static void test_cache(void) CHECK(stat(p79, &s1) == 0, "stat p79"); CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 re-get"); CHECK(stat(p, &s2) == 0 && s1.st_ino == s2.st_ino, "cache hit rewrote the file"); + + /* 假命中自愈(P2-1):外部删掉缓存文件后再取同页,应重新解压而不是返回死路径 */ + unlink(p79); + CHECK(access(p79, F_OK) != 0, "unlink p79"); + CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 after unlink"); + CHECK(access(p79, F_OK) == 0, "page79 not re-extracted after external unlink"); + CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, "count after self-heal=%d", + mr_cache_count()); mr_zip_book_free(&zb); /* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */ - joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picobrowser/data"); + joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picbrowser/data"); mr_cache_shutdown(); CHECK(mr_cache_init(data_dir) == 0, "cache init c2"); snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); @@ -514,7 +539,55 @@ static void test_cache(void) } unlink(keep); - /* ---- (c) 缓存不可用时优雅失败(不崩、不写文件) ---- */ + /* ---- (c) 单页 > 32MiB:文档化的一次性溢出(大页可读,其它条目按预算淘汰) ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c3/os.meow.picbrowser/data"); + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache init c3"); + + joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); + mr_zip_book small; + CHECK(mr_zip_book_init(&small, arc, 720, fake_load, fake_unload, NULL) == 2, + "small book init"); + char sp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&small, 0, sp, (int)sizeof sp) == 0, "small page"); + CHECK(mr_cache_count() == 1, "small cache count=%d", mr_cache_count()); + mr_zip_book_free(&small); + + joinp(arc, sizeof arc, g_feat, "hugepage.cbz"); + mr_zip_book huge; + CHECK(mr_zip_book_init(&huge, arc, 720, fake_load, fake_unload, NULL) == 1, + "hugepage init"); + char hp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&huge, 0, hp, (int)sizeof hp) == 0, "huge page path"); + CHECK(mr_cache_count() == 1, "overflow count=%d", mr_cache_count()); + CHECK(mr_cache_bytes() == (long long)HUGE_PAGE_LEN, + "overflow bytes=%lld (want %d)", mr_cache_bytes(), HUGE_PAGE_LEN); + CHECK(mr_cache_bytes() > MR_CACHE_MAX_BYTES, "huge page should exceed the budget"); + CHECK(access(sp, F_OK) != 0, "huge page should evict the small page"); + CHECK(access(hp, F_OK) == 0, "huge page file missing"); + CHECK(count_cache_files() == 1, "overflow left extra cache files"); + + mr_book hb; + mr_zip_book_bind(&hb, &huge); + int before_load = g_load_n; + meow_img *him = mr_book_open(&hb, 0); + CHECK(him != NULL && g_load_n == before_load + 1, "huge page not readable"); + mr_book_close(&hb, him); + mr_book_destroy(&hb); + + /* 再来一个小页:整体预算仍然生效,大页被淘汰 */ + joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); + mr_zip_book small2; + CHECK(mr_zip_book_init(&small2, arc, 720, fake_load, fake_unload, NULL) == 2, + "small2 init"); + CHECK(mr_zip_book_page_path(&small2, 0, sp, (int)sizeof sp) == 0, "small2 page"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() < MR_CACHE_MAX_BYTES, + "budget not restored: count=%d bytes=%lld", mr_cache_count(), + mr_cache_bytes()); + CHECK(access(hp, F_OK) != 0, "huge page should be evicted by the budget"); + mr_zip_book_free(&small2); + + /* ---- (d) 缓存不可用时优雅失败(不崩、不写文件) ---- */ mr_cache_shutdown(); CHECK(mr_cache_ready() == 0, "shutdown left cache ready"); joinp(path, sizeof path, g_lib, "vol2.cbz"); @@ -530,7 +603,7 @@ static void test_cache(void) CHECK(g_load_n == before, "decode callback called without cache"); mr_book_destroy(&b); - joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picobrowser/data"); + joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picbrowser/data"); CHECK(mr_cache_init(data_dir) == 0, "cache init app"); } @@ -815,7 +888,7 @@ int main(int argc, char **argv) } joinp(g_lib, sizeof g_lib, g_root, "library"); joinp(g_feat, sizeof g_feat, g_root, "features"); - joinp(g_data, sizeof g_data, g_root, "app/os.meow.picobrowser/data"); + joinp(g_data, sizeof g_data, g_root, "app/os.meow.picbrowser/data"); if (!run_generator(gen)) { printf("generator failed: %s\n", gen); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index a202aed..7d6083a 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -137,6 +137,10 @@ static void cache_prune(void) time_t oldest_t = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { + /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 + * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 + * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ + if (mr_cache_is_page_name(de->d_name)) continue; const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".bmp") != 0) continue; char full[MR_PATH_MAX + 96]; From 9f34417c49c48a10096fd728516d6b1119e73cd3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 11:55:04 +0800 Subject: [PATCH 12/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E5=8D=95=E5=B1=82?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=20+=20=E6=87=92=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=20M7/M11=20=E6=95=B4=E5=90=88=EF=BC=9B=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20P2=20=E4=BF=AE=E5=A4=8D=EF=BC=88M13?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 到 picbrowser(含 M7 条漫/续读、M11 CBZ 档案册)后的语义拼接: - 保留单层扫描重写:一次 opendir/readdir+lstat 只列当前层(目录 + 注入 archive_pages 时的 .cbz/.zip 档案文件),自然升序、隐藏项/符号链接跳过、2000 条截断标注; 系统目录/空目录/无图目录一律列出;彻底删除递归「子树含图」探测 - 保留并复用 M11 档案册分支:add_dir_entry / add_archive_entry / entry_subtitle / entry_badge / archive_pages_at / sibling_is_book / mr_library_scan_ex 注入; 档案册行同样先成行、页数由后台 probe(注入 zip 页数回调)懒算 - 保留 M7:mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变;为 adjacency 恢复 ignored_dir_name(仅册间衔接跳过系统/隐藏目录,浏览本身不隐藏); mr_library_format_subtitle 在补位后拼「· 续读 Pn」角标 - mr_library_probe_path 统一探测目录/档案册:mtime(秒+纳秒)+size 失效的 256 项 LRU + 互斥锁;评审 P2:只缓存「探测前后 stat 完全一致」的结果,期间变化则不写缓存 (消除「不完整结果绑定新 mtime」的假新鲜;下次调用重新探测) - UI:列表用 src2 每帧取行;后台线程探测优先于缩略图;档案册封面解压档内首图(M11); 行语义=书页数/缩略图、空目录「空目录」、无图/坏档「无图片」;可上溯至文件系统 /、 不可读目录提示并停留;TopAppBar「TF卡」回根;启动时根目录不可读也建空列表; 顶栏回根命中加 g_tb_home_idx>=0 防御 - tuku_internal.h:library_go_up 注释校正(0=到达文件系统 /,app.c 据此退出) - 测试:test_library 改单层语义;新增 browse_nav / browse_lazy / browse_perf / browse_ui_host(含评审 P2 的「探测期间变化不写缓存」用例);test_cont_adjacent A6 改两段式;test_cbz T8/T10 改两段式(坏档/无图档案列出但非书); run_tests.sh 保留双方全部条目(browse 4 套 + run_zip_tests/run_cbz_tests) - 复验:run_tests.sh 全 PASS(含 M11 zip/cbz)、run_webtoon_tests.sh 全 PASS; worktree 内 sdk/build_sdk.sh + build_tuku.sh 零警告静态 MIPS32r2 ELF --- tuku/logic/library.c | 476 +++++++++++++++++++------- tuku/logic/library.h | 153 +++++++-- tuku/test/run_tests.sh | 62 +++- tuku/test/test_browse_lazy.c | 443 ++++++++++++++++++++++++ tuku/test/test_browse_nav.c | 259 ++++++++++++++ tuku/test/test_browse_perf.c | 199 +++++++++++ tuku/test/test_browse_ui_host.c | 269 +++++++++++++++ tuku/test/test_cbz.c | 96 ++++-- tuku/test/test_cont_adjacent.c | 53 ++- tuku/test/test_library.c | 63 ++-- tuku/tuku_internal.h | 2 +- tuku/ui/library/library.c | 582 +++++++++++++++++++++++++------- 12 files changed, 2299 insertions(+), 358 deletions(-) create mode 100644 tuku/test/test_browse_lazy.c create mode 100644 tuku/test/test_browse_nav.c create mode 100644 tuku/test/test_browse_perf.c create mode 100644 tuku/test/test_browse_ui_host.c diff --git a/tuku/logic/library.c b/tuku/logic/library.c index 4db9545..7ab94c0 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -1,38 +1,57 @@ -/* logic/library.c — 书库目录扫描 + 自然排序(纯 C)。 +/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。 * - * 扫描分两步: - * 1) scan_info():递归探测一个目录是否「最终含图」,并带回直接图片数 / 含图子目录数 / - * 缩略图源(优先本目录自然序第一张,其次第一个含图子目录); - * 2) mr_library_scan():只对目标目录的**直接子目录**调用 scan_info(),把 nav=1 的目录 - * 收进结果并自然排序——因此根视图不会列出后代条目,进入子目录时再重新扫描该层。 + * 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构: + * - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环); + * - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出; + * - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 → + * 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。 * - * 内存:条目数组倍增分配;路径/名称定长,随 mr_library_free() 释放。 + * 懒判定(probe): + * - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图; + * - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的 + * archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项 + * LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。 + * - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成, + * 书行再拼 M7 的「· 续读 Pn」角标。 * - * M7 追加(不改扫描语义): - * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); - * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, - * 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 - * - * M11 追加(档案册,仍然不改目录扫描的主体语义): - * - 目标目录的**直接子文件**里 .cbz/.zip 也成行(行=文件名+档内页数,has_images=1); - * - 档案册探测由调用方注入(mr_lib_archive_fn),logic 层不依赖 zip 容器; - * 不注入时所有 *_ex 入口与 M7 行为逐字一致; - * - 册间衔接的兄弟表从「含图目录」扩成「含图目录 ∪ 含图片页的档案册」,同列自然序。 - * 小函数边界(add_dir_entry / add_archive_entry / entry_subtitle / entry_badge / - * sibling_is_book)刻意留给 M13 的书库重写复用。 + * M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数); + * 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。 */ #include "library.h" #include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include +#include #include #include #include #include #include -/* 系统目录不参与书库(SD 根上的 MeowOS 目录树)。 */ +#ifdef MR_LIBRARY_TEST_HOOKS +static mr_library_io_stats g_io; + +void mr_library_test_io_reset(void) +{ + memset(&g_io, 0, sizeof g_io); +} + +void mr_library_test_io_get(mr_library_io_stats *out) +{ + if (out) *out = g_io; +} + +#define IO_OPEN() (g_io.opendirs++) +#define IO_READDIR() (g_io.readdirs++) +#define IO_LSTAT() (g_io.lstats++) +#else +#define IO_OPEN() ((void)0) +#define IO_READDIR() ((void)0) +#define IO_LSTAT() ((void)0) +#endif + +/* 系统目录不参与**册间衔接**(M7 兄弟册表);浏览本身已放开(不隐藏)。 */ static int ignored_dir_name(const char *name) { static const char *const skip[] = { "MeowAPPs", "MeowData", "MeowOS", "lost+found" }; @@ -119,16 +138,7 @@ int mr_natural_cmp(const char *a, const char *b) return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); } -typedef struct scan_info { - int pages; /* 直接图片数 */ - int subdirs; /* 含图子目录数 */ - int archives; /* 直接档案册数(.cbz/.zip 且探测到图片页) */ - int nav; /* 1=本目录最终含书(可直接看或可导航到书) */ - char cover[MR_PATH_MAX]; /* 缩略图源 */ - char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ -} scan_info; - -/* 档案册探测(UI 注入;NULL=纯目录书库,行为与 M7 一致)。 */ +/* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */ static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) { if (!probe || !path || !path[0]) return 0; @@ -136,48 +146,234 @@ static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) return n > 0 ? n : 0; } -static void scan_dir(const char *path, int depth, int max_depth, scan_info *si, - mr_lib_archive_fn probe, void *ud) +/* ============================================================ */ +/* 单层探测 + mtime/size 失效缓存 */ +/* ============================================================ */ +int mr_library_probe_dir(const char *dir, mr_library_probe *out) { - memset(si, 0, sizeof *si); - DIR *d = opendir(path); - if (!d) return; + if (!out) return -1; + memset(out, 0, sizeof *out); + if (!dir || !dir[0]) return -1; + IO_OPEN(); + DIR *d = opendir(dir); + if (!d) return -1; + + char best[MR_NAME_MAX]; + best[0] = '\0'; + out->empty = 1; struct dirent *de; while ((de = readdir(d)) != NULL) { + IO_READDIR(); const char *name = de->d_name; - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (ignored_dir_name(name)) continue; + if (name[0] == '.') continue; /* 隐藏项不算内容 */ + out->empty = 0; + if (!mr_path_is_image(name)) continue; char full[MR_PATH_MAX]; - if (join_path(full, (int)sizeof full, path, name) != 0) continue; - + if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + IO_LSTAT(); struct stat st; - if (lstat(full, &st) != 0) continue; - if (S_ISLNK(st.st_mode)) continue; + if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; - if (S_ISDIR(st.st_mode)) { - if (depth >= max_depth) continue; - scan_info child; - scan_dir(full, depth + 1, max_depth, &child, probe, ud); - if (!child.nav) continue; - si->subdirs++; - if (!si->cover[0] && child.cover[0]) - copy_str(si->cover, (int)sizeof si->cover, child.cover); - } else if (S_ISREG(st.st_mode)) { - if (mr_path_is_image(name)) { - si->pages++; - if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { - copy_str(si->first_name, (int)sizeof si->first_name, name); - copy_str(si->cover, (int)sizeof si->cover, full); - } - } else if (mr_path_is_archive(name)) { - if (archive_pages_at(probe, ud, full) > 0) si->archives++; - } + out->pages++; + if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) { + copy_str(best, (int)sizeof best, name); + copy_str(out->cover, (int)sizeof out->cover, full); } } closedir(d); - si->nav = (si->pages > 0 || si->subdirs > 0 || si->archives > 0); + return 0; +} + +#define MR_PROBE_CACHE_MAX 256 + +typedef struct probe_cache_entry { + char path[MR_PATH_MAX]; + long long mtime_ns; + long long size; + mr_library_probe probe; + unsigned long long last_use; +} probe_cache_entry; + +static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX]; +static unsigned long long g_cache_clock; +static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER; + +static long long stat_mtime_ns(const struct stat *st) +{ +#if defined(__APPLE__) + return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec; +#else + return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec; +#endif +} + +static probe_cache_entry *cache_find_locked(const char *path) +{ + for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { + if (!g_cache[i]) continue; + if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i]; + } + return NULL; +} + +static void cache_put_locked(const char *path, long long mtime_ns, long long size, + const mr_library_probe *p) +{ + probe_cache_entry *e = cache_find_locked(path); + if (!e) { + int slot = -1; + for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) + if (!g_cache[i]) { slot = i; break; } + if (slot < 0) { /* 满:淘汰最久未用项 */ + int lru = 0; + unsigned long long oldest = g_cache[0]->last_use; + for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) { + if (g_cache[i]->last_use < oldest) { + oldest = g_cache[i]->last_use; + lru = i; + } + } + free(g_cache[lru]); + g_cache[lru] = NULL; + slot = lru; + } + e = (probe_cache_entry *)malloc(sizeof *e); + if (!e) return; /* 缓存只是加速:失败就每次现算 */ + if (copy_str(e->path, (int)sizeof e->path, path) != 0) { + free(e); + return; + } + g_cache[slot] = e; + } + e->mtime_ns = mtime_ns; + e->size = size; + e->probe = *p; + e->last_use = ++g_cache_clock; +} + +void mr_library_probe_cache_clear(void) +{ + pthread_mutex_lock(&g_cache_lock); + for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { + free(g_cache[i]); + g_cache[i] = NULL; + } + g_cache_clock = 0; + pthread_mutex_unlock(&g_cache_lock); +} + +/* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */ +static int probe_path_uncached(const char *path, int is_archive, + mr_lib_archive_fn archive_pages, void *ud, + mr_library_probe *out) +{ + struct stat st; + if (stat(path, &st) != 0) return -1; + if (is_archive) { + if (!archive_pages || !S_ISREG(st.st_mode)) return -1; + out->pages = archive_pages_at(archive_pages, ud, path); + out->empty = 0; + out->cover[0] = '\0'; + return 0; + } + if (!S_ISDIR(st.st_mode)) return -1; + return mr_library_probe_dir(path, out); +} + +int mr_library_probe_path(const char *path, int is_archive, + mr_lib_archive_fn archive_pages, void *ud, + mr_library_probe *out) +{ + if (!out) return -1; + memset(out, 0, sizeof *out); + if (!path || !path[0]) return -1; + + struct stat st; + if (stat(path, &st) != 0) return -1; + if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1; + + long long mtime_ns = stat_mtime_ns(&st); + long long size = (long long)st.st_size; + + pthread_mutex_lock(&g_cache_lock); + probe_cache_entry *hit = cache_find_locked(path); + if (hit && hit->mtime_ns == mtime_ns && hit->size == size) { + *out = hit->probe; + hit->last_use = ++g_cache_clock; + pthread_mutex_unlock(&g_cache_lock); + return 0; + } + pthread_mutex_unlock(&g_cache_lock); + + mr_library_probe p; + memset(&p, 0, sizeof p); + if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1; + + /* 评审 P2:只把「探测前后完全一致」的结果写入缓存。 + * 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整, + * 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */ + struct stat st2; + if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns && + (long long)st2.st_size == size) { + pthread_mutex_lock(&g_cache_lock); + cache_put_locked(path, mtime_ns, size, &p); + pthread_mutex_unlock(&g_cache_lock); + } + + *out = p; + return 0; +} + +int mr_library_probe_cached(const char *dir, mr_library_probe *out) +{ + return mr_library_probe_path(dir, 0, NULL, NULL, out); +} + +/* ============================================================ */ +/* 行状态:副标题 / 补位 / 行合成 */ +/* ============================================================ */ +/* 基础副标题(不含 M7 续读角标)。 */ +static void entry_subtitle(mr_library_entry *e) +{ + switch (e->probe_state) { + case MR_LIB_READY: + if (e->pages > 0) + snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, + e->pages == 1 ? "" : "s"); + else if (!e->is_archive && e->empty) + snprintf(e->subtitle, sizeof e->subtitle, "空目录"); + else + snprintf(e->subtitle, sizeof e->subtitle, "无图片"); + break; + case MR_LIB_FAILED: + snprintf(e->subtitle, sizeof e->subtitle, "无法读取"); + break; + default: + snprintf(e->subtitle, sizeof e->subtitle, "…"); + break; + } +} + +/* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。 + * 档案册以档案路径为册标识,同一套逻辑复用。 + * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ +static void entry_badge(mr_library_entry *e) +{ + if (e->has_images != 1) return; + char badge[24]; + if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; + int used = (int)strlen(e->subtitle); + if (used < (int)sizeof e->subtitle - 2) + snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); +} + +void mr_library_format_subtitle(mr_library_entry *e) +{ + if (!e) return; + entry_subtitle(e); + entry_badge(e); } void mr_library_free(mr_library *lib) @@ -187,6 +383,7 @@ void mr_library_free(mr_library *lib) lib->items = NULL; lib->count = 0; lib->cap = 0; + lib->truncated = 0; } static int lib_add(mr_library *lib, const mr_library_entry *e) @@ -210,51 +407,22 @@ static int entry_cmp(const void *pa, const void *pb) return mr_natural_cmp(a->name, b->name); } -/* 副标题(M11 扩展):直接含图 → pages;只有档案册 → books;否则 folders。 */ -static void entry_subtitle(mr_library_entry *e, int archive_books) -{ - if (e->has_images) - snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, - e->pages == 1 ? "" : "s"); - else if (archive_books > 0) - snprintf(e->subtitle, sizeof e->subtitle, "%d book%s", archive_books, - archive_books == 1 ? "" : "s"); - else - snprintf(e->subtitle, sizeof e->subtitle, "%d folder%s", e->subdirs, - e->subdirs == 1 ? "" : "s"); -} - -/* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 - * 档案册以档案路径为册标识,同一套逻辑复用。 - * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ -static void entry_badge(mr_library_entry *e) -{ - if (!e->has_images) return; - char badge[24]; - if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; - int used = (int)strlen(e->subtitle); - if (used < (int)sizeof e->subtitle - 2) - snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); -} - -/* 文件夹册(含可导航中间目录)一行。 */ -static int add_dir_entry(mr_library *lib, const char *name, const char *full, - const scan_info *si) +/* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */ +static int add_dir_entry(mr_library *lib, const char *name, const char *full) { mr_library_entry e; memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - copy_str(e.cover, (int)sizeof e.cover, si->cover); - e.pages = si->pages; - e.subdirs = si->subdirs; - e.has_images = (si->pages > 0); - entry_subtitle(&e, si->archives); - entry_badge(&e); + e.pages = -1; + e.has_images = -1; + e.probe_state = MR_LIB_PENDING; + mr_library_format_subtitle(&e); return lib_add(lib, &e); } -/* 档案册(.cbz/.zip)一行:文件即一本书,点入直接阅读。 +/* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。 + * pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。 * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ static int add_archive_entry(mr_library *lib, const char *name, const char *full, int pages) @@ -263,52 +431,58 @@ static int add_archive_entry(mr_library *lib, const char *name, const char *full memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - e.pages = pages; - e.subdirs = 0; - e.has_images = 1; - snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", pages, pages == 1 ? "" : "s"); - entry_badge(&e); + e.is_archive = 1; + if (pages < 0) { + e.pages = -1; + e.has_images = -1; + e.probe_state = MR_LIB_PENDING; + } else { + e.pages = pages; + e.has_images = (pages > 0) ? 1 : 0; + e.probe_state = MR_LIB_READY; + } + mr_library_format_subtitle(&e); return lib_add(lib, &e); } +/* ============================================================ */ +/* 单层扫描 */ +/* ============================================================ */ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, mr_lib_archive_fn archive_pages, void *ud) { + (void)max_depth; /* 兼容旧签名:扫描不再递归 */ + (void)ud; if (!lib) return -1; mr_library_free(lib); if (!dir || !dir[0]) return -1; - if (max_depth <= 0 || max_depth > MR_LIB_MAX_DEPTH) max_depth = MR_LIB_MAX_DEPTH; - + IO_OPEN(); DIR *d = opendir(dir); if (!d) return -1; struct dirent *de; while ((de = readdir(d)) != NULL) { + IO_READDIR(); const char *name = de->d_name; - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (ignored_dir_name(name)) continue; + if (name[0] == '.') continue; /* . .. 与隐藏项 */ char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + IO_LSTAT(); struct stat st; if (lstat(full, &st) != 0) continue; if (S_ISLNK(st.st_mode)) continue; - int rc = 0; - if (S_ISDIR(st.st_mode)) { - scan_info si; - scan_dir(full, 1, max_depth, &si, archive_pages, ud); - if (!si.nav) continue; - rc = add_dir_entry(lib, name, full, &si); - } else if (S_ISREG(st.st_mode) && mr_path_is_archive(name)) { - int pages = archive_pages_at(archive_pages, ud, full); - if (pages <= 0) continue; /* 坏档/无图片页:不当书 */ - rc = add_archive_entry(lib, name, full, pages); - } else { - continue; - } + int is_dir = S_ISDIR(st.st_mode); + int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) && + mr_path_is_archive(name)); + if (!is_dir && !is_arch) continue; + if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } + + int rc = is_dir ? add_dir_entry(lib, name, full) + : add_archive_entry(lib, name, full, -1); if (rc != 0) { closedir(d); mr_library_free(lib); @@ -317,7 +491,8 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, } closedir(d); - if (lib->count > 1) qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); + if (lib->count > 1) + qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); return lib->count; } @@ -326,6 +501,62 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); } +int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p) +{ + if (!lib || !p || idx < 0 || idx >= lib->count) return 0; + mr_library_entry *e = &lib->items[idx]; + e->pages = p->pages; + e->empty = p->empty; + e->has_images = (p->pages > 0) ? 1 : 0; + e->probe_state = MR_LIB_READY; + copy_str(e->cover, (int)sizeof e->cover, p->cover); + mr_library_format_subtitle(e); + return 1; +} + +int mr_library_mark_error(mr_library *lib, int idx) +{ + if (!lib || idx < 0 || idx >= lib->count) return 0; + mr_library_entry *e = &lib->items[idx]; + e->pages = -1; + e->empty = 0; + e->has_images = 0; + e->probe_state = MR_LIB_FAILED; + e->cover[0] = '\0'; + mr_library_format_subtitle(e); + return 1; +} + +int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) +{ + static char trunc_sub[48]; + static int trunc_ready; + + if (!lib || !out) return 0; + if (idx == lib->count && lib->truncated) { + if (!trunc_ready) { + snprintf(trunc_sub, sizeof trunc_sub, "仅显示前 %d 项", MR_LIB_SCAN_MAX); + trunc_ready = 1; + } + memset(out, 0, sizeof *out); + out->title = "已截断"; + out->subtitle = trunc_sub; + out->disabled = 1; + out->index = -1; + return 1; + } + if (idx < 0 || idx >= lib->count) return 0; + + const mr_library_entry *e = &lib->items[idx]; + memset(out, 0, sizeof *out); + out->title = e->name; + out->subtitle = e->subtitle; + out->is_book = (e->has_images == 1); + out->is_archive = e->is_archive; + out->index = idx; + return 1; +} + int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; @@ -346,7 +577,7 @@ int mr_path_parent(const char *path, char *out, int outsz) } /* ============================================================ */ -/* 册间衔接(M7) */ +/* 册间衔接(M7 / M11) */ /* ============================================================ */ int mr_library_dir_has_images(const char *dir) { @@ -427,7 +658,8 @@ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, parent, name) != 0) continue; struct stat st; - if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode)) continue; + if (lstat(full, &st) != 0) continue; + if (S_ISLNK(st.st_mode)) continue; if (!sibling_is_book(full, &st, archive_pages, ud)) continue; if (n >= cap) { diff --git a/tuku/logic/library.h b/tuku/logic/library.h index ba57c82..4149c88 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -1,19 +1,25 @@ -/* logic/library.h — 漫画书库扫描/排序(纯 C,不依赖 meow_ui.h)。 +/* logic/library.h — 书库目录浏览(纯 C,不依赖 meow_ui.h)。 * * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 - * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, - * 本层只读它来拼副标题,宿主测试用配置存根顶替。) - * 书库语义: - * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」;M11 起另列直接子文件里的 - * .cbz/.zip(档案册即一本书,与文件夹册同列自然序混排); - * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含书的中间目录(可继续导航); - * - 行的 pages=该目录直接图片数(档案册=档内图片页数);subdirs=其下含图子目录数; - * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover - * (档案册留空,UI 解压档内首图); - * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 * - * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 - * (. 开头、MeowAPPs/MeowData/MeowOS/lost+found)不参与书库。符号链接跳过(防环)。 + * M13 浏览语义(用户要求:性能 + 访问放开;参考 os.meow.files 的 fsx_scan_dir): + * - mr_library_scan / mr_library_scan_ex 只枚举**当前目录一层**:一次 opendir/readdir + + * 逐项 lstat,列出全部子目录(自然升序),不做任何递归「子树含图」探测; + * - 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, + * 空目录/无图目录一律列出并可进入;仅跳过 "."/".." 与其它 "." 开头的隐藏项、 + * 符号链接(防环); + * - 「直接含图目录=一本书」的判定改为**后台懒算**:mr_library_probe_dir 只读一层, + * mr_library_probe_path 按行类型(目录/档案册)走同一套 mtime 失效缓存(线程安全), + * 结果经 mr_library_apply_probe 补回行(页数/首图/副标题)。首帧/滚动不做子目录 IO; + * - 单目录条目上限 MR_LIB_SCAN_MAX(默认 2000):到上限后若还有条目则截断并置 + * lib->truncated=1(UI 用 mr_library_row_at 末尾的提示行标注)。 + * + * M11 档案册(.cbz/.zip):注入 archive_pages 回调时,当前层的档案文件也成行(与目录同列 + * 自然序混排);档案册的页数同样走懒探测(mr_library_probe_path + 注入回调),logic 层 + * 不依赖 zip 容器。未注入时(archive_pages==NULL)只列目录,等价 M7 书库。 + * + * M7 续读角标 / 册间衔接:mr_library_format_subtitle 在探测完成后拼「· 续读 Pn」 + * (mr_cont_badge);mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变。 */ #ifndef MR_LIBRARY_H #define MR_LIBRARY_H @@ -24,24 +30,55 @@ extern "C" { #define MR_PATH_MAX 512 #define MR_NAME_MAX 256 -#define MR_LIB_MAX_DEPTH 8 +#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描已不递归,该参数被忽略 */ + +/* 单目录列出的条目上限(超大目录截断 + UI 提示;可用 -D 覆写便于宿主测试)。 */ +#ifndef MR_LIB_SCAN_MAX +#define MR_LIB_SCAN_MAX 2000 +#endif + +/* 行懒判定状态:0=待探测(副标题占位「…」)/1=已探测/2=探测失败(不可读)。 */ +#define MR_LIB_PENDING 0 +#define MR_LIB_READY 1 +#define MR_LIB_FAILED 2 typedef struct mr_library_entry { char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */ - char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ - char cover[MR_PATH_MAX]; /* 缩略图源图(空=none;档案册由 UI 解压首图) */ - char subtitle[48]; /* 预先格式化好的 "N pages" / "N books" / "N folders" */ - int pages; /* 本目录直接图片数(档案册=档内图片页数) */ - int subdirs; /* 本目录下含图子目录数 */ - int has_images; /* 1=可直接阅读(直接含图 或 档案册) */ + char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader/探测用) */ + char cover[MR_PATH_MAX]; /* 后台探测填:自然序第一张直接子图(档案册留空,UI 解压首图) */ + char subtitle[48]; /* 占位「…」;探测后 "N pages"/"空目录"/"无图片"/"无法读取" */ + int pages; /* -1=未判定;>=0=直接图片数(档案册=档内图片页数) */ + int subdirs; /* 兼容字段(M11 及旧读者):新扫描不统计,恒 0 */ + int has_images; /* 1=书;0=非书;-1=未判定 */ + int probe_state; /* MR_LIB_PENDING / MR_LIB_READY / MR_LIB_FAILED */ + int empty; /* 1=空目录(目录且 probe_state==READY 后有效) */ + int is_archive; /* 1=.cbz/.zip 档案册行 */ } mr_library_entry; typedef struct mr_library { mr_library_entry *items; - int count; + int count; /* 当前层列出的条目数 */ int cap; + int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */ } mr_library; +/* 单行探测结果(目录=本层直接图片数;档案册=档内图片页数;均不递归)。 */ +typedef struct mr_library_probe { + int pages; /* 受支持图片数(png/jpg/jpeg/bmp;档案册=档内页数) */ + int empty; /* 1=目录没有可见子项(档案册恒 0) */ + char cover[MR_PATH_MAX]; /* 目录的自然序第一张图(档案册留空) */ +} mr_library_probe; + +/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO)。 */ +typedef struct mr_library_row { + const char *title; /* 主标题 */ + const char *subtitle; /* 副标题(可空串) */ + int is_book; /* 1=书(显示页数/缩略图,点入阅读,无进入箭头) */ + int is_archive; /* 1=档案册行 */ + int disabled; /* 1=提示行(不可点,如截断标注) */ + int index; /* 对应 mr_library.items 下标;提示行=-1 */ +} mr_library_row; + /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ int mr_path_is_image(const char *name); @@ -53,22 +90,19 @@ int mr_path_is_archive(const char *name); * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ int mr_natural_cmp(const char *a, const char *b); -/* 扫描 dir 的直接子目录,写入 lib(先释放 lib 旧内容)。 - * 返回条目数(>=0);dir 不可读返回 -1(lib 仍为空且可安全 mr_library_free)。 - * max_depth<=0 或超过 MR_LIB_MAX_DEPTH 时按上限处理。 */ +/* 扫描 dir 的**直接子目录**(单层;不读子目录内容),写入 lib(先释放 lib 旧内容)。 + * 返回条目数(>=0);dir 不可读/不存在/不是目录返回 -1(lib 仍为空且可安全 free)。 + * max_depth 仅为兼容旧调用保留,已忽略(不再递归)。 */ int mr_library_scan(mr_library *lib, const char *dir, int max_depth); /* ============================================================ */ /* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */ /* ------------------------------------------------------------ * logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。 - * archive_pages(path, ud) 对普通文件返回图片页数(>0=是一本书),0=不是/坏档。 - * 传 NULL 时行为与 mr_library_scan 完全一致(纯目录书库),M7 语义不变。 - * - * 档案册行的口径:name=文件名(含扩展名)、path=档案文件路径、pages=档内页数、 - * has_images=1(点入直接阅读)、cover 留空(缩略图由 UI 解压首图生成)、 - * 副标题 "N pages"、续读角标同文件夹册(按档案路径记)。 - * 只含档案册的子目录算「可导航」(nav),副标题写 "N books"。 + * archive_pages(path, ud) 返回档内图片页数(>0=是一本书),0=不是/坏档。 + * 传 NULL 时行为与 M7 完全一致(纯目录书库,档案文件不成行)。 + * 档案册行:name=文件名、path=档案路径、is_archive=1、cover 留空(UI 解压首图)、 + * 页数/书标记由后台 probe 经同一注入回调懒算。 * ============================================================ */ typedef int (*mr_lib_archive_fn)(const char *path, void *ud); @@ -79,14 +113,52 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, void mr_library_free(mr_library *lib); /* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。 - * 成功 0;参数非法/输出缓冲不足 -1。 */ + * 成功 0;参数非法/输出缓冲不足 -1。可一路向上到文件系统 '/'。 */ int mr_path_parent(const char *path, char *out, int outsz); +/* ---- 后台懒判定(单层探测;可由工作线程调用) ---- */ + +/* 单层探测目录 dir:直接图片数 + 自然序首图 + 是否空目录。不递归、不跟随符号链接。 + * 0=成功;-1=参数非法/不可读(out 清零)。 */ +int mr_library_probe_dir(const char *dir, mr_library_probe *out); + +/* 带缓存探测目录:按目录 mtime(秒+纳秒)+ size 失效;内部互斥锁,可多线程并发调用。 + * 0=成功(可能来自缓存);-1=不可读(失败不缓存)。 */ +int mr_library_probe_cached(const char *dir, mr_library_probe *out); + +/* 按行探测(目录或档案册),带同一套缓存: + * is_archive=0 → 目录(等价 mr_library_probe_cached); + * is_archive=1 → 调 archive_pages(path,ud) 取档内页数(cover 留空、empty=0); + * archive_pages 为 NULL 时返回 -1。 + * 0=成功;-1=参数非法/不可读。探测期间 mtime/size 变化则不写入缓存(避免假新鲜)。 */ +int mr_library_probe_path(const char *path, int is_archive, + mr_lib_archive_fn archive_pages, void *ud, + mr_library_probe *out); + +/* 清空探测缓存(目录整体切换/测试用)。 */ +void mr_library_probe_cache_clear(void); + +/* 把探测结果补回第 idx 行(pages/has_images/cover/subtitle/empty)。 + * 1=已应用;0=参数非法/越界。 */ +int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p); + +/* 标记第 idx 行探测失败(不可读):副标题「无法读取」,视为非书。1=已应用;0=非法。 */ +int mr_library_mark_error(mr_library *lib, int idx); + +/* 按行当前状态重算副标题(占位/页数/空目录/无图片/无法读取), + * 并在书行(has_images==1)后缀「· 续读 Pn」(M7 角标)。 */ +void mr_library_format_subtitle(mr_library_entry *e); + +/* 行合成(纯内存、零 IO、O(1);UI 线程每帧调用):idx 越界返回 0。 + * idx==count 且 lib->truncated 时输出末尾提示行(disabled=1)。 + * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ +int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); + /* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。 * 1=有;0=没有/不可读。 */ int mr_library_dir_has_images(const char *dir); -/* 册间衔接(M7):同父目录下按书库自然序排列的「可直接阅读」兄弟册。 +/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 @@ -94,12 +166,23 @@ int mr_library_dir_has_images(const char *dir); int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); /* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」, - * 两者同列按名称自然序混排(mission 基线:跨册衔接按同父目录自然序)。 - * 因此末页前进可以从文件夹册进到同目录的档案册,反之亦然。 + * 两者同列按名称自然序混排(跨册衔接按同父目录自然序)。 * archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); +#ifdef MR_LIBRARY_TEST_HOOKS +/* 宿主测试用 IO 计数(scan/probe_dir 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ +typedef struct mr_library_io_stats { + long opendirs; + long readdirs; + long lstats; +} mr_library_io_stats; + +void mr_library_test_io_reset(void); +void mr_library_test_io_get(mr_library_io_stats *out); +#endif + #ifdef __cplusplus } #endif diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 91a0e78..4d75e14 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,6 +1,9 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:书库扫描/自然排序/阅读方向(46 项) +# - test_library:单层目录扫描/自然排序/父目录/阅读方向 +# - test_browse_nav:访问放开(系统/空/无图目录可进入)、上溯到 /、不可读目录、截断 +# - test_browse_lazy:单层探测/懒判定补位/mtime 失效缓存/行合成 O(1)/M7 册间语义 +# - test_browse_perf:500 目录×8 层嵌套+2000 文件夹具,断言扫描单层且有界 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) # - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C, @@ -10,30 +13,48 @@ # 交付环境只有库+头时自动 SKIP。 # reader.c 的方向持久化只 include 配置/日志两个模块头;宿主测试用 test/settings_reader_stub.c # 顶替 meow_config_* 与 meow_log(所以下面各条都带 -I"$SDK" 与该存根)。 +# logic/library.c 的探测缓存用 pthread 互斥锁 → 相关编译带 -pthread。 set -e cd "$(dirname "$0")/.." SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" +OUT=build/host +mkdir -p "$OUT" gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_library.c logic/library.c logic/reader.c test/settings_reader_stub.c \ - -o test/test_library -./test/test_library + -pthread -o "$OUT/test_library" +"$OUT"/test_library + +gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ + test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + -pthread -o "$OUT/test_browse_nav" +"$OUT"/test_browse_nav + +gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ + test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + -pthread -o "$OUT/test_browse_lazy" +"$OUT"/test_browse_lazy + +gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ + test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + -pthread -o "$OUT/test_browse_perf" +"$OUT"/test_browse_perf gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_reader_core.c logic/library.c logic/reader.c \ logic/reader/page_source.c logic/reader/nav.c test/settings_reader_stub.c \ - -o test/test_reader_core -./test/test_reader_core + -pthread -o "$OUT/test_reader_core" +"$OUT"/test_reader_core gcc -Wall -Wextra -O1 -DMR_READER_TEST_HOOKS -I. -I"$SDK" \ test/test_settings_reader_persist.c logic/reader.c test/settings_reader_stub.c \ - -o test/test_settings_reader_persist -./test/test_settings_reader_persist + -o "$OUT"/test_settings_reader_persist +"$OUT"/test_settings_reader_persist gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_pin.c logic/pin.c \ - -o test/test_pin -./test/test_pin + -o "$OUT"/test_pin +"$OUT"/test_pin if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ @@ -47,10 +68,27 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ - -pthread -lm -o test/test_settings_lock_host - ./test/test_settings_lock_host + -pthread -lm -o "$OUT"/test_settings_lock_host + "$OUT"/test_settings_lock_host + + # 书库浏览页集成(src2 懒算补位/行语义/上溯至 / /不可读提示);同样需要 SDK 源码树。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_LIBRARY_UI_TEST_HOOKS \ + -I. -I"$SDK" -I"$SDK/include" \ + test/test_browse_ui_host.c ui/library/library.c logic/library.c \ + logic/reader.c logic/reader/page_source.c logic/reader/archive_source.c \ + logic/reader/page_cache.c logic/zip/zip.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_browse_ui_host + "$OUT"/test_browse_ui_host else - echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" + echo "SKIP test_settings_lock_host/test_browse_ui_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi # ---- 档案册(M11):zip 容器 + CBZ 书库/阅读器集成 ---- diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c new file mode 100644 index 0000000..b6843c8 --- /dev/null +++ b/tuku/test/test_browse_lazy.c @@ -0,0 +1,443 @@ +/* test_browse_lazy.c — 单层探测 / 懒判定 / mtime 失效缓存 / 行合成(纯 C,无 SDK)。 + * + * 覆盖: + * L1 单层探测:直接图片数、自然序首图、空目录标记;不递归(只有子目录含图的目录不算书); + * L2 懒判定接线:scan 后行全部 PENDING(占位副标题)→ apply_probe 后页数/首图/副标题/ + * 书标记;空目录「空目录」;无图目录「无图片」;探测失败「无法读取」且视为非书; + * L3 缓存:mtime 失效(同 mtime 命中不读盘;目录变化后重扫并刷新结果);并发调用线程安全; + * L3b 评审 P2:探测期间 mtime/size 变化的结果不写缓存(避免「不完整结果绑定新 mtime」); + * L4 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; + * L5 M7 兼容语义:「直接含图兄弟册」+ 自然序册间顺序(M7 mr_book_adjacent 的口径)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ + * test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * -pthread -o build/host/test_browse_lazy + */ +#include "logic/library.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs("x", f); + return fclose(f); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +/* 把目录 mtime 显式推后 3 秒(避免同秒内变化被缓存误命中)。 */ +static void bump_mtime(const char *path) +{ + struct timespec ts[2]; + ts[0].tv_sec = time(NULL) + 3; + ts[0].tv_nsec = 0; + ts[1] = ts[0]; + utimensat(AT_FDCWD, path, ts, 0); +} + +static const mr_library_entry *find_entry(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + return NULL; +} + +static int find_index(const mr_library *lib, const char *name, int *out) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) { *out = i; return 1; } + *out = -1; + return 0; +} + +/* ---- L1 + L2 ---- */ +static void test_probe_and_apply(const char *root) +{ + char book[MR_PATH_MAX], noimg[MR_PATH_MAX], empty[MR_PATH_MAX], dirs_only[MR_PATH_MAX]; + char deep[MR_PATH_MAX]; + joinp(book, sizeof book, root, "book"); + joinp(noimg, sizeof noimg, root, "noimg"); + joinp(empty, sizeof empty, root, "empty"); + joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); + joinp(deep, sizeof deep, root, "dirs_only/deep"); + + mr_library_probe p; + CHECK(mr_library_probe_dir(book, &p) == 0, "probe book"); + CHECK(p.pages == 2, "book pages=%d", p.pages); + CHECK(p.empty == 0, "book not empty"); + CHECK(strstr(p.cover, "page2.png") != NULL, "cover natural-first: %s", p.cover); + + CHECK(mr_library_probe_dir(noimg, &p) == 0 && p.pages == 0 && p.empty == 0, + "noimg: 0 pages, not empty"); + CHECK(mr_library_probe_dir(empty, &p) == 0 && p.pages == 0 && p.empty == 1, + "empty dir flagged"); + CHECK(mr_library_probe_dir(dirs_only, &p) == 0 && p.pages == 0 && p.empty == 0, + "dirs_only has no direct images (no recursion)"); + CHECK(mr_library_probe_dir(deep, &p) == 0 && p.pages == 1, "deep is a book"); + + char slash[MR_PATH_MAX]; + snprintf(slash, sizeof slash, "%s/", book); + CHECK(mr_library_probe_dir(slash, &p) == 0 && p.pages == 2, "trailing slash"); + + CHECK(mr_library_probe_dir("/no/such/dir", &p) == -1, "missing dir -> -1"); + CHECK(mr_library_probe_dir(NULL, &p) == -1, "NULL -> -1"); + + mr_library lib; + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) == 5, "scan 5 dirs (got %d)", lib.count); + + const mr_library_entry *e = find_entry(&lib, "book"); + CHECK(e && e->probe_state == MR_LIB_PENDING && e->pages == -1, + "book pending after scan"); + + int idx_book, idx_noimg, idx_empty, idx_dirs; + CHECK(find_index(&lib, "book", &idx_book), "book index"); + CHECK(find_index(&lib, "noimg", &idx_noimg), "noimg index"); + CHECK(find_index(&lib, "empty", &idx_empty), "empty index"); + CHECK(find_index(&lib, "dirs_only", &idx_dirs), "dirs_only index"); + if (idx_book < 0 || idx_noimg < 0 || idx_empty < 0 || idx_dirs < 0) { + mr_library_free(&lib); + return; + } + + CHECK(mr_library_probe_dir(lib.items[idx_book].path, &p) == 0, "probe row book"); + CHECK(mr_library_apply_probe(&lib, idx_book, &p) == 1, "apply book"); + e = &lib.items[idx_book]; + CHECK(e->has_images == 1 && e->pages == 2 && e->probe_state == MR_LIB_READY, + "book ready"); + CHECK(strcmp(e->subtitle, "2 pages") == 0, "book subtitle=%s", e->subtitle); + CHECK(strstr(e->cover, "page2.png") != NULL, "book row cover=%s", e->cover); + + CHECK(mr_library_probe_dir(lib.items[idx_noimg].path, &p) == 0 && + mr_library_apply_probe(&lib, idx_noimg, &p) == 1, "apply noimg"); + e = &lib.items[idx_noimg]; + CHECK(e->has_images == 0 && strcmp(e->subtitle, "无图片") == 0, + "noimg subtitle=%s", e->subtitle); + + CHECK(mr_library_probe_dir(lib.items[idx_empty].path, &p) == 0 && + mr_library_apply_probe(&lib, idx_empty, &p) == 1, "apply empty"); + e = &lib.items[idx_empty]; + CHECK(e->has_images == 0 && e->empty == 1 && strcmp(e->subtitle, "空目录") == 0, + "empty subtitle=%s", e->subtitle); + + CHECK(mr_library_mark_error(&lib, idx_dirs) == 1, "mark error"); + e = &lib.items[idx_dirs]; + CHECK(e->probe_state == MR_LIB_FAILED && e->has_images == 0 && + strcmp(e->subtitle, "无法读取") == 0, "failed subtitle=%s", e->subtitle); + + CHECK(mr_library_apply_probe(&lib, 999, &p) == 0, "apply out of range"); + CHECK(mr_library_mark_error(&lib, -1) == 0, "mark_error out of range"); + + mr_library_free(&lib); +} + +/* 档案册探测回调(P2 用例):可注入「探测期间文件变化」。 */ +typedef struct arch_probe_ctx { + int calls; + int bump_times; /* 前 N 次调用里改动文件 mtime(模拟探测期间变化) */ +} arch_probe_ctx; + +static int arch_probe(const char *path, void *ud) +{ + arch_probe_ctx *c = (arch_probe_ctx *)ud; + c->calls++; + if (c->calls <= c->bump_times) bump_mtime(path); + return 1; +} + +/* ---- L3b:评审 P2——探测期间 mtime 变化不得写入缓存(消除假新鲜) ---- */ +static void test_cache_race(const char *root) +{ + char arc[MR_PATH_MAX]; + joinp(arc, sizeof arc, root, "race.cbz"); + CHECK(touch_file(arc) == 0, "create race.cbz"); + + mr_library_probe_cache_clear(); + mr_library_probe p; + + /* 稳定文件:第二次走缓存,回调只调一次。 */ + arch_probe_ctx stable = { 0, 0 }; + CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, + "archive probe"); + CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, + "archive probe (cached)"); + CHECK(stable.calls == 1, "stable archive cached (calls=%d)", stable.calls); + + /* 探测期间文件变化:本次结果不得入缓存 → 下一次仍重新探测。 */ + mr_library_probe_cache_clear(); + arch_probe_ctx race = { 0, 1 }; + CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, + "racing archive probe"); + CHECK(race.calls == 1, "racing probe called once (calls=%d)", race.calls); + CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, + "racing archive probe again"); + CHECK(race.calls == 2, "changed-during-probe result NOT cached (calls=%d)", race.calls); + /* 第二次稳定后落缓存,第三次命中。 */ + CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, + "racing archive probe third"); + CHECK(race.calls == 2, "stable result cached after third call (calls=%d)", race.calls); + mr_library_probe_cache_clear(); +} + +/* ---- L3:mtime 失效缓存 + 并发 ---- */ +#define THREADS 6 +typedef struct th_arg { + const char *dir; + int want; + int *fail; + pthread_mutex_t *mu; +} th_arg; + +static void *probe_thread(void *arg) +{ + th_arg *a = (th_arg *)arg; + for (int i = 0; i < 200; i++) { + mr_library_probe p; + int ok = (mr_library_probe_cached(a->dir, &p) == 0 && p.pages == a->want); + if (!ok) { + pthread_mutex_lock(a->mu); + (*a->fail)++; + pthread_mutex_unlock(a->mu); + } + } + return NULL; +} + +static void test_cache(const char *root) +{ + char book[MR_PATH_MAX]; + joinp(book, sizeof book, root, "book"); + + mr_library_probe_cache_clear(); + mr_library_io_stats st; + mr_library_probe p; + + mr_library_test_io_reset(); + CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache miss probe"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1, "cache miss opens once (got %ld)", st.opendirs); + + mr_library_test_io_reset(); + CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache hit"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 0 && st.lstats == 0, "cache hit does no IO (open=%ld stat=%ld)", + st.opendirs, st.lstats); + + char img[MR_PATH_MAX]; + joinp(img, sizeof img, book, "page30.png"); + CHECK(touch_file(img) == 0, "add image"); + bump_mtime(book); + mr_library_test_io_reset(); + CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 3, + "cache invalidated by mtime (pages=%d)", p.pages); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1, "invalidation re-opens (got %ld)", st.opendirs); + + CHECK(mr_library_probe_cached("/no/such/dir", &p) == -1, "missing dir -> -1"); + + pthread_t th[THREADS]; + th_arg args[THREADS]; + int failures = 0; + pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER; + int started = 0; + for (int t = 0; t < THREADS; t++) { + args[t].dir = book; + args[t].want = 3; + args[t].fail = &failures; + args[t].mu = μ + if (pthread_create(&th[t], NULL, probe_thread, &args[t]) == 0) started++; + } + for (int t = 0; t < started; t++) pthread_join(th[t], NULL); + CHECK(started == THREADS, "threads started (%d)", started); + CHECK(failures == 0, "concurrent probe failures=%d", failures); + pthread_mutex_destroy(&mu); +} + +/* ---- L4:行合成 O(1) + 截断提示行 ---- */ +static void test_rows(const char *root) +{ + mr_library lib; + memset(&lib, 0, sizeof lib); + mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + + mr_library_io_stats st; + mr_library_test_io_reset(); + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + int rows = 0; + for (int r = 0; r < 2000; r++) + for (int i = 0; i < lib.count; i++) { + mr_library_row row; + if (mr_library_row_at(&lib, i, &row)) rows++; + } + clock_gettime(CLOCK_MONOTONIC, &t1); + mr_library_test_io_get(&st); + long long us = (long long)(t1.tv_sec - t0.tv_sec) * 1000000LL + + (t1.tv_nsec - t0.tv_nsec) / 1000LL; + CHECK(rows == 2000 * lib.count, "rows synthesized (%d)", rows); + CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, + "row synth is pure memory (open=%ld rd=%ld stat=%ld)", + st.opendirs, st.readdirs, st.lstats); + CHECK(us < 2000000, "row synth bounded (%lldus)", us); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated"); + lib.truncated = 1; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1, + "truncation info row"); + CHECK(strcmp(row.title, "已截断") == 0, "truncation title=%s", row.title); + CHECK(strstr(row.subtitle, "2000") != NULL, "truncation subtitle=%s", row.subtitle); + mr_library_free(&lib); +} + +/* ---- L5:M7 兼容语义(直接含图兄弟册 + 自然序) ---- */ +static void collect_books(const char *parent, char names[][MR_NAME_MAX], int max, int *out_n) +{ + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = 0; + if (mr_library_scan(&lib, parent, MR_LIB_MAX_DEPTH) > 0) { + for (int i = 0; i < lib.count && n < max; i++) { + mr_library_probe p; + if (mr_library_probe_cached(lib.items[i].path, &p) == 0 && p.pages > 0) { + snprintf(names[n], MR_NAME_MAX, "%s", lib.items[i].name); + n++; + } + } + } + mr_library_free(&lib); + *out_n = n; +} + +static int book_index(char names[][MR_NAME_MAX], int n, const char *cur) +{ + for (int i = 0; i < n; i++) + if (strcmp(names[i], cur) == 0) return i; + return -1; +} + +static void test_m7_semantics(const char *root) +{ + char parent[MR_PATH_MAX]; + joinp(parent, sizeof parent, root, "series"); + + char names[16][MR_NAME_MAX]; + int n = 0; + collect_books(parent, names, 16, &n); + CHECK(n == 3, "series books=%d (vol1/vol2/vol10; vol6 is not a book)", n); + if (n == 3) { + CHECK(strcmp(names[0], "vol1") == 0, "book[0]=%s", names[0]); + CHECK(strcmp(names[1], "vol2") == 0, "book[1]=%s", names[1]); + CHECK(strcmp(names[2], "vol10") == 0, "book[2]=%s", names[2]); + } + CHECK(book_index(names, n, "vol6") < 0, "dirs-only vol6 skipped (direct images only)"); + CHECK(book_index(names, n, ".hidden") < 0, "hidden skipped"); + CHECK(book_index(names, n, "MeowOS") < 0, "system dir without direct images skipped"); + + int i2 = book_index(names, n, "vol2"); + CHECK(i2 >= 0 && i2 + 1 < n && strcmp(names[i2 + 1], "vol10") == 0, + "vol2 next = vol10 (natural order, vol6 skipped)"); + CHECK(book_index(names, n, "vol10") == n - 1, "vol10 is last (no next)"); + CHECK(book_index(names, n, "vol1") == 0, "vol1 is first (no prev)"); +} + +int main(void) +{ + char tmpl[] = "./mr_lazy_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + const char *dirs[] = { "book", "noimg", "empty", "dirs_only/deep", "series" }; + for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + joinp(p, sizeof p, root, dirs[i]); + CHECK(mkdir_p(p) == 0, "mkdir %s", p); + } + + joinp(p, sizeof p, root, "book/page2.png"); CHECK(touch_file(p) == 0, "page2"); + joinp(p, sizeof p, root, "book/page10.jpg"); CHECK(touch_file(p) == 0, "page10"); + joinp(p, sizeof p, root, "book/notes.txt"); CHECK(touch_file(p) == 0, "notes"); + joinp(p, sizeof p, root, "noimg/readme.txt"); CHECK(touch_file(p) == 0, "readme"); + joinp(p, sizeof p, root, "dirs_only/deep/a.png"); CHECK(touch_file(p) == 0, "deep a"); + + /* M7 语义夹具:vol1/vol2/vol10 直接含图;vol6 只有子目录含图;.hidden / MeowOS 干扰项 */ + const char *mk[] = { "series/vol1/001.png", "series/vol2/001.png", + "series/vol10/001.png", "series/vol6/sub/001.png", + "series/.hidden/001.png", "series/MeowOS/deep/001.png" }; + for (unsigned i = 0; i < sizeof mk / sizeof mk[0]; i++) { + joinp(p, sizeof p, root, mk[i]); + /* 先建父目录,再落文件 */ + char dir[MR_PATH_MAX]; + snprintf(dir, sizeof dir, "%s", p); + char *slash = strrchr(dir, '/'); + if (slash) { *slash = '\0'; CHECK(mkdir_p(dir) == 0, "mkdir %s", dir); } + CHECK(touch_file(p) == 0, "image %s", p); + } + + test_probe_and_apply(root); + test_cache(root); + test_cache_race(root); + test_rows(root); + test_m7_semantics(root); + + rm_rf(root); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_browse_nav.c b/tuku/test/test_browse_nav.c new file mode 100644 index 0000000..58240a6 --- /dev/null +++ b/tuku/test/test_browse_nav.c @@ -0,0 +1,259 @@ +/* test_browse_nav.c — 浏览访问放开 / 上溯父目录 / 截断标注(纯 C,无 SDK)。 + * + * 覆盖(M13 用户要求②): + * N1 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, + * 空目录/无图目录都列出且可进入(scan 返回 0 而不是 -1); + * N2 隐藏项/普通文件/符号链接仍不列(防环、防噪声); + * N3 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此提示并停留; + * N4 上溯父目录:mr_path_parent 可一路走到文件系统 "/"(不再以 TF 卡根为界); + * N5 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ + * test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * -pthread -o build/host/test_browse_nav + */ +#include "logic/library.h" + +#include +#include +#include +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs("x", f); + return fclose(f); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) { chmod(path, 0700); return unlink(path); } + chmod(path, 0700); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +static const mr_library_entry *find_entry(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + return NULL; +} + +/* ---- N1/N2 ---- */ +static void test_open_access(const char *root) +{ + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n == 7, "root lists 7 dirs (got %d)", n); + CHECK(lib.truncated == 0, "root not truncated"); + + static const char *want[7] = { + "book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg" + }; + for (int i = 0; i < 7 && i < lib.count; i++) + CHECK(strcmp(lib.items[i].name, want[i]) == 0, + "sort[%d]=%s got %s", i, want[i], lib.items[i].name); + + for (int i = 0; i < 7; i++) + CHECK(find_entry(&lib, want[i]) != NULL, "system/empty/noimg dir listed: %s", want[i]); + CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); + CHECK(find_entry(&lib, "linkdir") == NULL, "symlink dir excluded"); + CHECK(find_entry(&lib, "loose.png") == NULL, "file excluded"); + + /* 可进入:每个列出的目录都能被 scan(0 项也算成功,不等于 -1)。 */ + char p[MR_PATH_MAX]; + for (int i = 0; i < lib.count; i++) { + mr_library child; + memset(&child, 0, sizeof child); + CHECK(mr_library_scan(&child, lib.items[i].path, MR_LIB_MAX_DEPTH) >= 0, + "enter %s", lib.items[i].name); + mr_library_free(&child); + } + + joinp(p, sizeof p, root, "MeowOS"); + mr_library syslib; + memset(&syslib, 0, sizeof syslib); + CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 1 && + find_entry(&syslib, "sub") != NULL, "system dir browsable"); + mr_library_free(&syslib); + + joinp(p, sizeof p, root, "empty"); + mr_library empty; + memset(&empty, 0, sizeof empty); + CHECK(mr_library_scan(&empty, p, MR_LIB_MAX_DEPTH) == 0, "empty dir enterable (0 items)"); + mr_library_free(&empty); + + mr_library_free(&lib); +} + +/* ---- N3 ---- */ +static void test_unreadable(const char *root) +{ + mr_library lib; + char p[MR_PATH_MAX]; + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, "/no/such/dir/m13", MR_LIB_MAX_DEPTH) == -1, "missing -> -1"); + CHECK(lib.count == 0 && lib.truncated == 0, "failed scan leaves empty lib"); + mr_library_free(&lib); + + joinp(p, sizeof p, root, "loose.png"); + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, p, MR_LIB_MAX_DEPTH) == -1, "file (not dir) -> -1"); + mr_library_free(&lib); + + if (geteuid() != 0) { /* root 下 EACCES 不生效,跳过权限用例 */ + joinp(p, sizeof p, root, "noread"); + CHECK(mkdir_p(p) == 0, "mkdir noread"); + CHECK(chmod(p, 0000) == 0, "chmod 000"); + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, p, MR_LIB_MAX_DEPTH) == -1, "unreadable -> -1"); + CHECK(lib.count == 0, "unreadable lib empty"); + mr_library_free(&lib); + chmod(p, 0700); + } +} + +/* ---- N4 ---- */ +static void test_up_to_root(void) +{ + char cur[MR_PATH_MAX]; + snprintf(cur, sizeof cur, "/usr/data/mnt/sd_0/a/b/c"); + int steps = 0; + while (strcmp(cur, "/") != 0 && steps < 32) { + char up[MR_PATH_MAX]; + int r = mr_path_parent(cur, up, (int)sizeof up); + CHECK(r == 0, "parent of %s", cur); + if (r != 0) break; + CHECK(strcmp(up, cur) != 0, "parent strictly shorter at %s", cur); + memcpy(cur, up, sizeof cur); + steps++; + } + CHECK(strcmp(cur, "/") == 0, "reached filesystem root ('%s')", cur); + CHECK(steps == 7, "steps to / = %d (want 7)", steps); + + char up[MR_PATH_MAX]; + CHECK(mr_path_parent("/", up, (int)sizeof up) == 0 && strcmp(up, "/") == 0, + "root parent stays /"); + CHECK(mr_path_parent("/usr/data/mnt/sd_0", up, (int)sizeof up) == 0 && + strcmp(up, "/usr/data/mnt") == 0, + "TF root can go above mount point: %s", up); +} + +/* ---- N5 ---- */ +static void test_truncation(void) +{ + CHECK(MR_LIB_SCAN_MAX == 2000, "default cap is 2000 (got %d)", MR_LIB_SCAN_MAX); + + char tmpl[] = "./mr_nav_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return; + + int total = MR_LIB_SCAN_MAX + 8; + char p[MR_PATH_MAX]; + for (int i = 0; i < total; i++) { + snprintf(p, sizeof p, "%s/d%04d", root, i); + if (mkdir(p, 0755) != 0) { CHECK(0, "mkdir %s", p); break; } + } + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n == MR_LIB_SCAN_MAX, "cap applied: n=%d", n); + CHECK(lib.truncated == 1, "truncated flag set"); + CHECK(lib.count == MR_LIB_SCAN_MAX, "count == cap"); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1, + "truncation row present"); + CHECK(strcmp(row.title, "已截断") == 0, "truncation row title=%s", row.title); + + mr_library_free(&lib); + rm_rf(root); +} + +int main(void) +{ + char tmpl[] = "./mr_nav_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + const char *dirs[] = { "MeowAPPs", "MeowData", "MeowOS/sub", "lost+found", + "book", "empty", "noimg", ".hidden" }; + for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + joinp(p, sizeof p, root, dirs[i]); + CHECK(mkdir_p(p) == 0, "mkdir %s", p); + } + joinp(p, sizeof p, root, "MeowOS/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "book/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "noimg/readme.txt"); + touch_file(p); + joinp(p, sizeof p, root, "loose.png"); + touch_file(p); + /* 符号链接目录不参与浏览(有权限才建;建不了不影响其余断言)。 */ + char target[MR_PATH_MAX], link[MR_PATH_MAX]; + joinp(target, sizeof target, root, "book"); + joinp(link, sizeof link, root, "linkdir"); + symlink(target, link); + + test_open_access(root); + test_unreadable(root); + test_up_to_root(); + + rm_rf(root); + test_truncation(); + + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c new file mode 100644 index 0000000..596f9d4 --- /dev/null +++ b/tuku/test/test_browse_perf.c @@ -0,0 +1,199 @@ +/* test_browse_perf.c — 单层扫描性能/有界性验收(纯 C,无 SDK)。 + * + * 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个文件。 + * 断言(对应 M13 任务⑤): + * P1 首帧扫描只做单层:opendir 次数 == 1(旧实现会递归打开数千个目录); + * lstat 次数 == 本层条目数;耗时有界; + * P2 后台探测同样只读一层:每个目录一次 opendir,不进入其嵌套子树; + * P3 行合成(UI src/bind 的数据面)纯内存、零 IO、O(1); + * P4 深目录直接扫描也只打开它自己。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ + * test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * -pthread -o build/host/test_browse_perf + */ +#include "logic/library.h" + +#include +#include +#include +#include +#include +#include +#include + +#define PERF_DIRS 500 +#define PERF_DEPTH 8 +#define PERF_FILES 2000 + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs("x", f); + return fclose(f); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static long long now_us(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000LL; +} + +int main(void) +{ + char tmpl[] = "./mr_perf_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + /* 根目录:2000 个文件 + 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */ + for (int f = 0; f < PERF_FILES; f++) { + snprintf(p, sizeof p, "%s/f%04d.txt", root, f); + if (touch_file(p) != 0) { CHECK(0, "file %s", p); break; } + } + for (int d = 0; d < PERF_DIRS; d++) { + char deep[MR_PATH_MAX]; + snprintf(deep, sizeof deep, "%s/d%04d", root, d); + if (mkdir_p(deep) != 0) { CHECK(0, "mkdir %s", deep); break; } + snprintf(p, sizeof p, "%s/001.png", deep); /* 顶层目录自身直接含图(=一本书) */ + if (touch_file(p) != 0) { CHECK(0, "touched top image"); break; } + for (int l = 1; l <= PERF_DEPTH; l++) { + char sub[MR_PATH_MAX]; + snprintf(sub, sizeof sub, "%s/l%d", deep, l); + if (mkdir(sub, 0755) != 0) { CHECK(0, "mkdir %s", sub); break; } + snprintf(deep, sizeof deep, "%s", sub); + } + snprintf(p, sizeof p, "%s/001.png", deep); + if (touch_file(p) != 0) { CHECK(0, "touched deep image"); break; } + } + + /* ---- P1:首帧扫描只做单层 ---- */ + mr_library lib; + memset(&lib, 0, sizeof lib); + mr_library_test_io_reset(); + long long t0 = now_us(); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + long long scan_us = now_us() - t0; + mr_library_io_stats st; + mr_library_test_io_get(&st); + + CHECK(n == PERF_DIRS, "scan lists %d dirs (got %d)", PERF_DIRS, n); + CHECK(lib.truncated == 0, "not truncated"); + CHECK(st.opendirs == 1, "single-layer scan: opendir=%ld (want 1)", st.opendirs); + CHECK(st.lstats == PERF_FILES + PERF_DIRS, + "lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS); + CHECK(scan_us < 1500000LL, "scan bounded: %lldus", scan_us); + if (lib.count > 1) { + CHECK(strcmp(lib.items[0].name, "d0000") == 0, "first=%s", lib.items[0].name); + CHECK(strcmp(lib.items[lib.count - 1].name, "d0499") == 0, + "last=%s", lib.items[lib.count - 1].name); + } + printf(" scan: %lldus, %d dirs, opendir=%ld lstat=%ld\n", + scan_us, n, st.opendirs, st.lstats); + + /* ---- P2:后台探测每目录只开一次,不进入嵌套子树 ---- */ + mr_library_test_io_reset(); + long long p0 = now_us(); + int bad = 0; + for (int i = 0; i < lib.count; i++) { + mr_library_probe probe; + if (mr_library_probe_cached(lib.items[i].path, &probe) != 0 || probe.pages != 1) + bad++; + } + long long probe_us = now_us() - p0; + mr_library_test_io_get(&st); + CHECK(bad == 0, "all top dirs have 1 direct image (bad=%d)", bad); + CHECK(st.opendirs == PERF_DIRS, + "probe stays shallow: opendir=%ld (want %d, not %d)", + st.opendirs, PERF_DIRS, PERF_DIRS * (PERF_DEPTH + 1)); + CHECK(st.lstats == PERF_DIRS, + "probe lstats only image candidates: %ld (want %d)", st.lstats, PERF_DIRS); + CHECK(probe_us < 5000000LL, "probe bounded: %lldus", probe_us); + printf(" probe: %lldus, opendir=%ld lstat=%ld\n", probe_us, st.opendirs, st.lstats); + + /* ---- P3:行合成(bind/src 数据面)纯内存 O(1) ---- */ + mr_library_test_io_reset(); + long long r0 = now_us(); + int rows = 0; + for (int r = 0; r < 1000; r++) + for (int i = 0; i < lib.count; i++) { + mr_library_row row; + if (mr_library_row_at(&lib, i, &row)) rows++; + } + long long row_us = now_us() - r0; + mr_library_test_io_get(&st); + CHECK(rows == 1000 * lib.count, "rows synthesized (%d)", rows); + CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, + "row synthesis does no IO (open=%ld rd=%ld stat=%ld)", + st.opendirs, st.readdirs, st.lstats); + CHECK(row_us < 1000000LL, "row synthesis bounded: %lldus", row_us); + printf(" rows: %lldus for %d rows, zero IO\n", row_us, rows); + + /* ---- P4:直接扫描深层目录同样只打开它自己 ---- */ + char deep[MR_PATH_MAX]; + snprintf(deep, sizeof deep, "%s", lib.items[0].path); + for (int l = 1; l <= PERF_DEPTH; l++) { + char sub[MR_PATH_MAX]; + snprintf(sub, sizeof sub, "%s/l%d", deep, l); + snprintf(deep, sizeof deep, "%s", sub); + } + mr_library_test_io_reset(); + mr_library deep_lib; + memset(&deep_lib, 0, sizeof deep_lib); + CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 0, "deep leaf has no subdir"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1, "deep scan opens once (got %ld)", st.opendirs); + CHECK(st.lstats == 1, "deep scan lstats only its image (got %ld)", st.lstats); + mr_library_free(&deep_lib); + + mr_library_free(&lib); + rm_rf(root); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c new file mode 100644 index 0000000..b1bbe73 --- /dev/null +++ b/tuku/test/test_browse_ui_host.c @@ -0,0 +1,269 @@ +/* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。 + * + * 目的(M13): + * U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、 + * on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到); + * U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」; + * U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出); + * 「TF卡」回根动作回到书库根; + * U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅。 + * + * 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩; + * 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以 + * -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;本测试另打桩 mr_reader_open_dir/mr_nav_report。 + */ +#include "tuku_internal.h" +#include "logic/library.h" + +#include +#include +#include +#include +#include +#include + +/* ---- ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) ---- */ +int library_test_load(const char *dir); +void library_test_set_root(const char *root); +const char *library_test_root(void); +const char *library_test_dir(void); +void library_test_set_dir(const char *dir); +int library_test_rows(void); +int library_test_src(int idx, meow_list_item *item); +int library_test_hint_active(void); +const char *library_test_hint(void); +int library_test_go_root(void); +int library_test_go_up(void); + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +/* app.c 的导航上报在测试里不需要网络侧实现。 */ +void mr_nav_report(void) {} + +/* reader 打桩:只记录被打开的册路径(本测试不建 reader 页)。 */ +static char g_opened[MR_PATH_MAX]; +static int g_opened_n; +void mr_reader_open_dir(const char *path) +{ + snprintf(g_opened, sizeof g_opened, "%s", path ? path : ""); + g_opened_n++; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs("x", f); + return fclose(f); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) { chmod(path, 0700); return unlink(path); } + chmod(path, 0700); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +static int row_index_for(const char *name) +{ + int rows = library_test_rows(); + for (int i = 0; i < rows; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.title && strcmp(it.title, name) == 0) return i; + } + return -1; +} + +static void wait_ready(int ms) +{ + for (int t = 0; t < ms / 10; t++) { + library_on_tick(); + int ready = 1; + int rows = library_test_rows(); + for (int i = 0; i < rows; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; } + } + if (ready) return; + usleep(10000); + } +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_browse_ui_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX]; + snprintf(root, sizeof root, "%s/library", data); + char p[MR_PATH_MAX]; + const char *dirs[] = { "book", "empty", "noimg", "deep/sub", "MeowOS/sub" }; + for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + joinp(p, sizeof p, root, dirs[i]); + if (mkdir_p(p) != 0) { printf("SKIP: mkdir %s failed\n", p); return 0; } + } + joinp(p, sizeof p, root, "book/page2.png"); touch_file(p); + joinp(p, sizeof p, root, "book/page10.jpg"); touch_file(p); + joinp(p, sizeof p, root, "noimg/readme.txt"); touch_file(p); + joinp(p, sizeof p, root, "deep/sub/001.png"); touch_file(p); + joinp(p, sizeof p, root, "MeowOS/sub/m.png"); touch_file(p); + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + meow_view *lib = library_view_create(); + library_test_set_root(root); + CHECK(strcmp(library_test_root(), root) == 0, "test root installed"); + meow_view_page_push(ACT_LIBRARY); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active"); + + /* ---- U1:src2 行 + 懒算补位 ---- */ + CHECK(library_test_load(root) == 1, "load fixture"); + CHECK(library_test_rows() == 5, "5 rows (got %d)", library_test_rows()); + + static const char *want[5] = { "book", "deep", "empty", "MeowOS", "noimg" }; + for (int i = 0; i < 5; i++) { + meow_list_item it; + CHECK(library_test_src(i, &it) == 1, "src row %d", i); + CHECK(it.title && strcmp(it.title, want[i]) == 0, "row[%d]=%s (want %s)", + i, it.title ? it.title : "(null)", want[i]); + CHECK(it.subtitle && strcmp(it.subtitle, "…") == 0, + "row[%d] starts with placeholder (got %s)", i, it.subtitle ? it.subtitle : ""); + CHECK(it.right == MEOW_LIST_RIGHT_ARROW, "row[%d] has enter arrow while pending", i); + } + + wait_ready(3000); + /* U2:补位后的行语义 */ + for (int i = 0; i < 5; i++) { + meow_list_item it; + library_test_src(i, &it); + CHECK(it.subtitle && strcmp(it.subtitle, "…") != 0, + "row[%d] subtitle filled (got %s)", i, it.subtitle ? it.subtitle : ""); + } + int ib = row_index_for("book"); + int ie = row_index_for("empty"); + int in = row_index_for("noimg"); + int id = row_index_for("deep"); + meow_list_item it; + CHECK(ib >= 0 && library_test_src(ib, &it) && strcmp(it.subtitle, "2 pages") == 0, + "book subtitle=%s", it.subtitle ? it.subtitle : ""); + CHECK(it.right == MEOW_LIST_RIGHT_NONE, "book row has no enter arrow (opens reader)"); + CHECK(ie >= 0 && library_test_src(ie, &it) && strcmp(it.subtitle, "空目录") == 0, + "empty subtitle=%s", it.subtitle ? it.subtitle : ""); + CHECK(in >= 0 && library_test_src(in, &it) && strcmp(it.subtitle, "无图片") == 0, + "noimg subtitle=%s", it.subtitle ? it.subtitle : ""); + CHECK(id >= 0 && library_test_src(id, &it) && strcmp(it.subtitle, "无图片") == 0, + "deep (images only in subdir) is not a book: %s", it.subtitle ? it.subtitle : ""); + + /* ---- 行点击:书 → reader;空目录 → 进入 ---- */ + int list_top = meow_ui_statusbar_height() + 56; + g_opened_n = 0; + library_on_tap(120, list_top + ib * 56 + 28); + CHECK(g_opened_n == 1 && strstr(g_opened, "/book") != NULL, + "tap book opens reader (%s)", g_opened); + CHECK(strcmp(library_test_dir(), root) == 0, "tap book does not navigate"); + + library_on_tap(120, list_top + ie * 56 + 28); + char empty_path[MR_PATH_MAX]; + joinp(empty_path, sizeof empty_path, root, "empty"); + CHECK(strcmp(library_test_dir(), empty_path) == 0, + "tap empty dir enters it (%s)", library_test_dir()); + CHECK(library_test_rows() == 0, "empty dir shows empty list"); + + /* ---- U3:导航(逐级上溯 / 到 / 退出 / 回 TF 根) ---- */ + CHECK(library_test_go_up() == 1, "go_up from empty consumed"); + CHECK(strcmp(library_test_dir(), root) == 0, "go_up to fixture root"); + + char deep[MR_PATH_MAX]; + joinp(deep, sizeof deep, root, "deep/sub"); + CHECK(library_test_load(deep) == 1, "load deep/sub"); + CHECK(library_test_go_up() == 1, "go_up from deep/sub"); + joinp(p, sizeof p, root, "deep"); + CHECK(strcmp(library_test_dir(), p) == 0, "now at deep"); + + /* 「TF卡」回根 */ + CHECK(library_test_load(deep) == 1, "reload deep/sub"); + CHECK(library_test_go_root() == 1 && strcmp(library_test_dir(), root) == 0, + "TF卡 action returns to library root"); + + /* 上溯到文件系统 /:go_up 在 / 返回 0(app.c 据此退出)。 + * 宿主(Termux)读不了 "/":从 /usr 返回时 load("/") 失败,仍应切到 "/" 的空列表 + * (否则会卡在 /usr 无法退出),下一次返回=退出 App。 */ + library_test_set_dir("/usr"); + CHECK(library_test_go_up() == 1, "go_up toward / consumed"); + CHECK(strcmp(library_test_dir(), "/") == 0, "reached / (unreadable root fallback)"); + CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)"); + CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture"); + + /* ---- U4:不可读目录 → 停留 + 提示 ---- */ + CHECK(library_test_load(root) == 1, "back to fixture root"); + char noread[MR_PATH_MAX]; + joinp(noread, sizeof noread, root, "noread"); + if (geteuid() != 0) { + CHECK(mkdir(noread, 0755) == 0, "mkdir noread"); + chmod(noread, 0000); + int ok = library_test_load(noread); + CHECK(ok == 0, "unreadable dir load fails"); + CHECK(strcmp(library_test_dir(), root) == 0, "stays in previous dir (%s)", + library_test_dir()); + CHECK(library_test_hint_active(), "hint banner shown: %s", library_test_hint()); + CHECK(strstr(library_test_hint(), "无法读取") != NULL, + "hint text mentions unreadable: %s", library_test_hint()); + chmod(noread, 0700); + } else { + printf(" SKIP unreadable-dir case (running as root)\n"); + } + + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "browse UI checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c index 4e4b29f..97cc465 100644 --- a/tuku/test/test_cbz.c +++ b/tuku/test/test_cbz.c @@ -15,12 +15,13 @@ * T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。 * T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy), * 越界 open 返回 NULL,close_auto 幂等。 - * T8 书库列档:.cbz 与文件夹册同列自然序(bookA < box < vol2.cbz < Vol10.cbz), - * 行=文件名+页数;坏档/无图档案不列;只含档案册的目录写 "N books"; - * 不注入探测回调时行为与 M7 完全一致(纯目录书库)。 + * T8 书库列档(M13 单层 + 懒判定):.cbz 与文件夹册同列自然序(bookA < box < ... < + * Vol10.cbz);扫描期只成行、副标题占位,探测补位后才有页数/书标记; + * 坏档/无图片页档案仍列出但为非书(访问放开,不再隐藏); + * 不注入探测回调时档案文件不成行(M7 语义),目录仍按单层列出。 * T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz), * 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。 - * T10 续读角标:档案路径作为册标识,副标题后缀「· 续读 Pn」。 + * T10 续读角标(M13 两段式):档案路径作为册标识,probe 补位后副标题后缀「· 续读 Pn」。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \ @@ -715,19 +716,49 @@ static const mr_library_entry *lib_find(const mr_library *lib, const char *name) return NULL; } +/* M13:模拟 UI 后台探测补位(目录单层探测 / 档案册注入页数回调)。 */ +static void lib_probe_all(mr_library *lib) +{ + for (int i = 0; i < lib->count; i++) { + mr_library_probe p; + if (mr_library_probe_path(lib->items[i].path, lib->items[i].is_archive, + archive_pages_probe, NULL, &p) == 0) + mr_library_apply_probe(lib, i, &p); + else + mr_library_mark_error(lib, i); + } +} + static void test_library(void) { mr_test_cfg_clear(); + /* M13:扫描只做单层枚举——目录(全部列出,含无图的 box)与档案文件(.cbz/.zip) + * 都先成行、副标题占位;书/非书与页数由后台 probe 补位。 */ mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); - CHECK(n == 4, "library rows=%d (want bookA, box, vol2.cbz, Vol10.cbz)", n); - if (n == 4) { - CHECK(strcmp(lib.items[0].name, "bookA") == 0, "row0=%s", lib.items[0].name); - CHECK(strcmp(lib.items[1].name, "box") == 0, "row1=%s", lib.items[1].name); - CHECK(strcmp(lib.items[2].name, "vol2.cbz") == 0, "row2=%s", lib.items[2].name); - CHECK(strcmp(lib.items[3].name, "Vol10.cbz") == 0, "row3=%s", lib.items[3].name); + CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n); + static const char *want[8] = { + "bookA", "box", "broken.cbz", "emptypages.cbz", + "notabook.zip", "truncated.cbz", "vol2.cbz", "Vol10.cbz" + }; + for (int i = 0; i < 8 && i < lib.count; i++) { + CHECK(strcmp(lib.items[i].name, want[i]) == 0, + "row[%d]=%s (want %s)", i, lib.items[i].name, want[i]); + CHECK(lib.items[i].probe_state == MR_LIB_PENDING && lib.items[i].pages == -1, + "%s pending after scan", lib.items[i].name); + CHECK(strcmp(lib.items[i].subtitle, "…") == 0, + "%s placeholder subtitle=%s", lib.items[i].name, lib.items[i].subtitle); + } + + /* 后台补位(等价 UI 的 on_tick):目录走单层目录探测,档案走注入的 zip 页数回调。 */ + for (int i = 0; i < lib.count; i++) { + mr_library_probe p; + int ok = (mr_library_probe_path(lib.items[i].path, lib.items[i].is_archive, + archive_pages_probe, NULL, &p) == 0); + CHECK(ok, "%s probed", lib.items[i].name); + if (ok) mr_library_apply_probe(&lib, i, &p); } const mr_library_entry *e = lib_find(&lib, "bookA"); @@ -737,15 +768,17 @@ static void test_library(void) CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s", e ? e->cover : ""); + /* box 只含档案册:目录本身不直接含图 → 不是书(M13 不做递归/子表统计)。 */ e = lib_find(&lib, "box"); - CHECK(e && e->has_images == 0 && e->subdirs == 0, "box fields"); - CHECK(e && strcmp(e->subtitle, "2 books") == 0, "box subtitle=%s", + CHECK(e && e->has_images == 0 && e->subdirs == 0 && e->is_archive == 0, + "box fields"); + CHECK(e && strcmp(e->subtitle, "无图片") == 0, "box subtitle=%s", e ? e->subtitle : ""); CHECK(e && e->cover[0] == '\0', "box cover should be empty"); e = lib_find(&lib, "vol2.cbz"); - CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0, - "vol2.cbz fields"); + CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0 && + e->is_archive == 1, "vol2.cbz fields"); CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s", e ? e->subtitle : ""); CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)"); @@ -756,19 +789,24 @@ static void test_library(void) CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0, "Vol10.cbz pages/subtitle"); - CHECK(lib_find(&lib, "notabook.zip") == NULL, "text-only zip listed"); - CHECK(lib_find(&lib, "emptypages.cbz") == NULL, "no-image cbz listed"); - CHECK(lib_find(&lib, "broken.cbz") == NULL, "broken cbz listed"); - CHECK(lib_find(&lib, "truncated.cbz") == NULL, "truncated cbz listed"); - CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz listed"); + /* 坏档/无图片页档案:列出但探测为非书(M13 访问放开,不再隐藏)。 */ + static const char *not_book[] = { "notabook.zip", "emptypages.cbz", + "broken.cbz", "truncated.cbz" }; + for (unsigned i = 0; i < sizeof not_book / sizeof not_book[0]; i++) { + e = lib_find(&lib, not_book[i]); + CHECK(e && e->is_archive == 1 && e->has_images == 0 && e->pages == 0 && + strcmp(e->subtitle, "无图片") == 0, + "%s: not a book (subtitle=%s)", not_book[i], e ? e->subtitle : "(missing)"); + } + CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed"); mr_library_free(&lib); - /* 不注入探测回调:与 M7 完全一致(档案册不参与,只含档案册的目录也不 nav)。 */ + /* 不注入探测回调:M7 纯目录书库(档案文件不成行,目录仍按 M13 单层列出)。 */ memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 1, - "legacy scan rows=%d (want 1: bookA)", lib.count); - CHECK(lib.count >= 1 && strcmp(lib.items[0].name, "bookA") == 0, - "legacy scan row0"); + CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 2, + "legacy scan rows=%d (want 2: bookA, box)", lib.count); + CHECK(lib.count >= 2 && strcmp(lib.items[0].name, "bookA") == 0 && + strcmp(lib.items[1].name, "box") == 0, "legacy scan rows"); mr_library_free(&lib); } @@ -835,11 +873,16 @@ static void test_badge_cover(void) mr_test_cfg_clear(); mr_cont_save(vol2, 2, MR_MODE_WEBTOON); + /* M13 两段式:扫描期只有占位副标题;后台 probe 补位后才出现页数与续读角标。 */ mr_library lib; memset(&lib, 0, sizeof lib); CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, - NULL) == 4, "rescan for badge"); + NULL) == 8, "rescan for badge (single-layer rows)"); const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); + CHECK(e && strcmp(e->subtitle, "…") == 0, "vol2 placeholder before probe: %s", + e ? e->subtitle : ""); + lib_probe_all(&lib); + e = lib_find(&lib, "vol2.cbz"); CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s", e ? e->subtitle : ""); CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s", @@ -848,10 +891,11 @@ static void test_badge_cover(void) CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge"); mr_library_free(&lib); - /* 清除记录后角标消失 */ + /* 清除记录后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ mr_cont_save(vol2, 0, MR_MODE_WEBTOON); memset(&lib, 0, sizeof lib); mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + lib_probe_all(&lib); e = lib_find(&lib, "vol2.cbz"); CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone"); mr_library_free(&lib); diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index 298f604..d2b682f 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -6,11 +6,12 @@ * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; - * A6 书库扫描接线:有续读记录的册,行副标题出现「· 续读 Pn」角标。 + * A6 书库扫描接线(M13 两段式):scan 只列单层、副标题占位「…」; + * probe/on_tick 补位后出现页数与「· 续读 Pn」角标。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ - * test/settings_reader_stub.c -o test/test_cont_adjacent + * test/settings_reader_stub.c -pthread -o test/test_cont_adjacent * 或 test/run_webtoon_tests.sh。 */ #include "logic/library.h" @@ -158,6 +159,20 @@ static void test_adjacent(const char *root) CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); } +/* 模拟 UI 后台探测:逐行单层 probe 并补位(page>0 的书行由 format_subtitle 拼续读角标)。 */ +static void probe_all_rows(mr_library *lib) +{ + for (int i = 0; i < lib->count; i++) { + mr_library_probe p; + if (mr_library_probe_cached(lib->items[i].path, &p) == 0) + mr_library_apply_probe(lib, i, &p); + else + mr_library_mark_error(lib, i); + } +} + +/* A6 两段式(M13):scan 只做单层枚举、行副标题是占位「…」;后台 probe/on_tick 补位后 + * 才有页数与「· 续读 Pn」角标。 */ static void test_library_badge(const char *root) { mr_test_cfg_clear(); @@ -173,28 +188,52 @@ static void test_library_badge(const char *root) int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); CHECK(n > 0, "library scan n=%d", n); + /* 第一段:扫描期不判定,占位副标题。 */ int seen1 = 0, seen2 = 0; for (int i = 0; i < lib.count; i++) { mr_library_entry *e = &lib.items[i]; if (strcmp(e->name, "vol1") == 0) { seen1 = 1; - CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); - CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); + CHECK(e->probe_state == MR_LIB_PENDING && e->pages == -1, + "vol1 pending after scan"); + CHECK(strcmp(e->subtitle, "…") == 0, "vol1 placeholder subtitle=%s", e->subtitle); } if (strcmp(e->name, "vol2") == 0) { seen2 = 1; - CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); - CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); + CHECK(e->probe_state == MR_LIB_PENDING && strcmp(e->subtitle, "…") == 0, + "vol2 placeholder subtitle=%s", e->subtitle); } } CHECK(seen1 == 1, "vol1 listed"); CHECK(seen2 == 1, "vol2 listed"); + + /* 第二段:后台探测补位后,页数与续读角标出现。 */ + probe_all_rows(&lib); + seen1 = seen2 = 0; + for (int i = 0; i < lib.count; i++) { + mr_library_entry *e = &lib.items[i]; + if (strcmp(e->name, "vol1") == 0) { + seen1 = 1; + CHECK(e->has_images == 1 && e->pages == 1, "vol1 probed pages=%d", e->pages); + CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); + CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); + } + if (strcmp(e->name, "vol2") == 0) { + seen2 = 1; + CHECK(e->has_images == 1 && e->pages == 1, "vol2 probed pages=%d", e->pages); + CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); + CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); + } + } + CHECK(seen1 == 1, "vol1 listed after probe"); + CHECK(seen2 == 1, "vol2 listed after probe"); mr_library_free(&lib); - /* 记录清除后角标消失 */ + /* 记录清除后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ mr_cont_save(vol1, 0, MR_MODE_WEBTOON); memset(&lib, 0, sizeof lib); CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "rescan"); + probe_all_rows(&lib); for (int i = 0; i < lib.count; i++) if (strcmp(lib.items[i].name, "vol1") == 0) CHECK(strstr(lib.items[i].subtitle, "续读") == NULL, "cleared badge gone"); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 65b0e2e..2b4e2a6 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -3,12 +3,14 @@ * 覆盖: * T1 自然排序:数字段数值序(page2pages == 2 && e->has_images == 1 && e->subdirs == 0, "Series 10 pages=2"); - CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "Series 10 subtitle '%s'", e ? e->subtitle : ""); - CHECK(e && strstr(e->cover, "page1.png") != NULL, "Series 10 cover natural-first '%s'", - e ? e->cover : ""); - - e = find_entry(&lib, "Series 2"); - CHECK(e && e->pages == 1 && e->has_images == 1, "Series 2 pages=1"); - - e = find_entry(&lib, "Nested"); - CHECK(e && e->pages == 0 && e->has_images == 0 && e->subdirs == 1, "Nested intermediate"); - CHECK(e && strcmp(e->subtitle, "1 folder") == 0, "Nested subtitle '%s'", e ? e->subtitle : ""); - CHECK(e && strstr(e->cover, "Deeper/a.png") != NULL, "Nested cover from descendant '%s'", - e ? e->cover : ""); - - CHECK(find_entry(&lib, "Empty") == NULL, "empty dir excluded"); - CHECK(find_entry(&lib, "Series 3") == NULL, "gif-only dir excluded"); + /* 访问放开:系统目录/空目录/无图目录都列出;隐藏项/文件不列。 */ + CHECK(find_entry(&lib, "MeowAPPs") != NULL, "system dir listed"); + CHECK(find_entry(&lib, "Empty") != NULL, "empty dir listed"); + CHECK(find_entry(&lib, "Series 3") != NULL, "no-image dir listed"); + CHECK(find_entry(&lib, "Nested") != NULL, "intermediate dir listed"); CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); - CHECK(find_entry(&lib, "MeowAPPs") == NULL, "system dir excluded"); + CHECK(find_entry(&lib, "root.png") == NULL, "loose image file excluded"); - /* 深度 1:Nested 不再可探测到 Deeper 的图,应被排除。 */ + /* 首帧不做懒判定:所有行待探测(副标题占位)。 */ + for (int i = 0; i < lib.count; i++) { + CHECK(lib.items[i].pages == -1, "%s pages pending", lib.items[i].name); + CHECK(lib.items[i].has_images == -1, "%s book state pending", lib.items[i].name); + CHECK(lib.items[i].probe_state == MR_LIB_PENDING, "%s probe pending", lib.items[i].name); + CHECK(strcmp(lib.items[i].subtitle, "…") == 0, "%s subtitle placeholder", lib.items[i].name); + } + + /* max_depth 参数只为兼容旧调用保留:不再递归,结果一致。 */ mr_library lib1; memset(&lib1, 0, sizeof lib1); - CHECK(mr_library_scan(&lib1, root, 1) == 3, "max_depth=1 excludes Nested"); + CHECK(mr_library_scan(&lib1, root, 1) == 7, "max_depth ignored (single layer)"); mr_library_free(&lib1); mr_library_free(&lib); diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h index caad8db..dffae61 100644 --- a/tuku/tuku_internal.h +++ b/tuku/tuku_internal.h @@ -38,7 +38,7 @@ typedef struct mr_page_ctrl { /* ---- 书库页(ui/library/library.c) ---- */ meow_view *library_view_create(void); int library_at_root(void); /* 1=当前在 MR_ROOT_DIR */ -int library_go_up(void); /* 返回上一级;1=已上移,0=已在根 */ +int library_go_up(void); /* 上溯父目录:1=已上移/提示停留,0=已在文件系统 "/"(app.c 据此退出) */ void library_on_tap(int x, int y); void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ void library_on_touch_down(int x, int y); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 7d6083a..0332796 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,20 +1,27 @@ -/* ui/library/library.c — 图库根页(ACT_LIBRARY): +/* ui/library/library.c — 图库浏览页(ACT_LIBRARY): * - * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 - * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册); - * M11 起 .cbz/.zip 也作为「册」与文件夹并列(行=封面+文件名+页数,点入直接阅读)。 - * 行数据由 logic/library 扫描并自然排序;档案册页数/首图由 logic/reader 的 zip 页源提供。 + * TopAppBar(标题 + 返回 + 「TF卡」回根 + ⚙)+ meow_listview(src2 行数据源)。 * - * 流畅度(用户指令优先): - * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, - * 完成后在 on_tick 补位并只 invalidate 列表视口; - * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData//cache - * (BMP 原始缩略图,文件名含源 mtime+size,源文件变化自动失效;≥64 张按 - * mtime LRU 删除);档案册封面=档内首图,先解压到同一 cache(page_cache 管 LRU); - * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 + * 浏览语义(M13,用户要求:性能 + 访问放开): + * - 首帧只做**单层**枚举(logic/library.c:一次 opendir/readdir+lstat), + * 不做任何递归「子树含图」探测;列表行 = 当前目录的直接子目录(M11 起还包括 + * .cbz/.zip 档案册,与目录自然序混排); + * - 系统目录/空目录/无图目录都列出且可进入;可一路向上到文件系统 '/', + * 左缘返回由 app.c 逐级上溯、到 '/' 才退出 App; + * - 「直接含图目录=一本书」与档案册页数改为后台懒算:行仍在 PENDING 时副标题「…」, + * 后台线程用 mr_library_probe_path(单层 + mtime/size 失效缓存)算出页数/首图, + * on_tick 补位后只 invalidate 列表视口——首帧/滚动/命中判定都不读子目录/档案; + * - 不可读/不存在的目录:停留在原目录,顶部横幅提示 2.5s。 * - * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 - * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 + * 行绑定(列表 src2 模式):meow_listview_create(..., rows, lib_src, ...) 每帧/每次 + * invalidate 对可见行重新取行;lib_src 只读内存里的 g_lib + 缩略图缓存,并把「待探测/ + * 待缩略图」请求投递到有界队列(不做 IO),因此懒算结果补位后下一帧即可上屏。 + * + * 缩略图:后台线程 meow_img_downscale 到 96px,落磁盘缓存 MeowData//cache + * (BMP,文件名含源 mtime+size,源变化自动失效;≥64 张按 mtime LRU 删除)。 + * 档案册(M11)封面=档内首图:先解压到 page_cache,再按普通图片解码。 + * + * 绘制/颜色全部取 meow_theme_get() 语义色;主题切换由 app.c 的 listener 整帧 invalidate。 */ #include "tuku_internal.h" #include "logic/reader/page_source.h" @@ -37,9 +44,13 @@ #define MR_TOPAPPBAR_H 56 #define MR_DEFAULT_ROW_H 56 #define MR_CACHE_MAX_FILES 64 +#define MR_HINT_H 44 +#define MR_HINT_MS 2500 -#define MR_REQ_MAX 64 -#define MR_RES_MAX 16 +#define MR_TREQ_MAX 64 +#define MR_TRES_MAX 16 +#define MR_PQ_MAX 128 +#define MR_PRES_MAX 48 typedef struct thumb_req { int gen; @@ -53,14 +64,32 @@ typedef struct thumb_res { meow_img *img; } thumb_res; +typedef struct probe_job { + int gen; + int idx; + int is_archive; + char path[MR_PATH_MAX]; +} probe_job; + +typedef struct probe_res { + int gen; + int idx; + int is_archive; + char path[MR_PATH_MAX]; + int ok; + mr_library_probe probe; +} probe_res; + static mr_page_ctrl g_ctrl; static mr_library g_lib; static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR; +static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 「TF卡」回根目标(宿主测试可改) */ static meow_widget *g_tb; static meow_widget *g_lv; static meow_scroll *g_scroll; static int g_list_x, g_list_y, g_list_w, g_list_h; +static int g_tb_home_idx = -1; static struct { int idx; @@ -68,7 +97,7 @@ static struct { } g_thumbs[MR_THUMB_MAX]; static int g_thumb_n; -/* ---- 异步缩略图请求/结果队列 ---- */ +/* ---- 后台工作线程:探测队列(优先)+ 缩略图队列 ---- */ static pthread_t g_worker; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; @@ -76,21 +105,41 @@ static int g_worker_on; static int g_worker_stop; static int g_gen; -static thumb_req g_req[MR_REQ_MAX]; -static int g_req_head, g_req_tail, g_req_n; -static char g_inflight[MR_PATH_MAX]; +static probe_job g_pq[MR_PQ_MAX]; +static int g_pq_head, g_pq_tail, g_pq_n; +static char g_probe_inflight[MR_PATH_MAX]; -static thumb_res g_res[MR_RES_MAX]; -static int g_res_n; +static probe_res g_pres[MR_PRES_MAX]; +static int g_pres_n; -/* ---- 磁盘缩略图缓存 ---- */ +static thumb_req g_treq[MR_TREQ_MAX]; +static int g_treq_head, g_treq_tail, g_treq_n; +static char g_thumb_inflight[MR_PATH_MAX]; + +static thumb_res g_tres[MR_TRES_MAX]; +static int g_tres_n; + +/* ---- 磁盘缩略图缓存(与解压页缓存同目录) ---- */ static char g_cache_dir[MR_PATH_MAX]; static int g_cache_on; +/* ---- 顶部提示横幅(不可读目录等) ---- */ +static int g_hint_id; +static char g_hint[160]; +static uint64_t g_hint_until; +static int g_hint_x, g_hint_y, g_hint_w, g_hint_h; + static int g_drag; static int g_dragged; static int g_drag_last_y; +/* 书库扫描/后台探测的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ +static int cbz_archive_pages(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + /* ============================================================ */ /* 磁盘缓存(BMP 原始缩略图;文件名含源 mtime+size 做失效) */ /* ============================================================ */ @@ -137,10 +186,6 @@ static void cache_prune(void) time_t oldest_t = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { - /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 - * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 - * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ - if (mr_cache_is_page_name(de->d_name)) continue; const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".bmp") != 0) continue; char full[MR_PATH_MAX + 96]; @@ -256,34 +301,71 @@ static meow_img *thumb_load(const char *cover) return im; } -static void *thumb_worker(void *arg) +static void probe_push_res_locked(const probe_job *job, int ok, const mr_library_probe *p) +{ + if (g_pres_n >= MR_PRES_MAX) return; + probe_res *r = &g_pres[g_pres_n++]; + r->gen = job->gen; + r->idx = job->idx; + r->is_archive = job->is_archive; + snprintf(r->path, sizeof r->path, "%s", job->path); + r->ok = ok; + if (ok && p) r->probe = *p; + else memset(&r->probe, 0, sizeof r->probe); +} + +static void thumb_push_res_locked(const thumb_req *req, meow_img *im) +{ + if (g_tres_n >= MR_TRES_MAX) { meow_img_free(im); return; } + thumb_res *r = &g_tres[g_tres_n++]; + r->gen = req->gen; + r->idx = req->idx; + r->img = im; +} + +static void *lib_worker(void *arg) { (void)arg; for (;;) { - thumb_req req; + int kind = 0; /* 1=探测 2=缩略图 */ + probe_job pj; + thumb_req tr; + pthread_mutex_lock(&g_lock); - while (!g_worker_stop && g_req_n == 0) + while (!g_worker_stop && g_pq_n == 0 && g_treq_n == 0) pthread_cond_wait(&g_cv, &g_lock); if (g_worker_stop) { pthread_mutex_unlock(&g_lock); break; } - req = g_req[g_req_head]; - g_req_head = (g_req_head + 1) % MR_REQ_MAX; - g_req_n--; - snprintf(g_inflight, sizeof g_inflight, "%s", req.cover); - pthread_mutex_unlock(&g_lock); - - meow_img *im = thumb_load(req.cover); - - pthread_mutex_lock(&g_lock); - g_inflight[0] = '\0'; - if (im && !g_worker_stop && g_res_n < MR_RES_MAX) { - g_res[g_res_n].gen = req.gen; - g_res[g_res_n].idx = req.idx; - g_res[g_res_n].img = im; - g_res_n++; - } else if (im) { - meow_img_free(im); + if (g_pq_n > 0) { /* 探测优先:页数/角标比重缩略图更影响可读性 */ + kind = 1; + pj = g_pq[g_pq_head]; + g_pq_head = (g_pq_head + 1) % MR_PQ_MAX; + g_pq_n--; + snprintf(g_probe_inflight, sizeof g_probe_inflight, "%s", pj.path); + } else { + kind = 2; + tr = g_treq[g_treq_head]; + g_treq_head = (g_treq_head + 1) % MR_TREQ_MAX; + g_treq_n--; + snprintf(g_thumb_inflight, sizeof g_thumb_inflight, "%s", tr.cover); } pthread_mutex_unlock(&g_lock); + + if (kind == 1) { + mr_library_probe p; + int ok = (mr_library_probe_path(pj.path, pj.is_archive, + cbz_archive_pages, NULL, &p) == 0); + pthread_mutex_lock(&g_lock); + g_probe_inflight[0] = '\0'; + if (!g_worker_stop) probe_push_res_locked(&pj, ok, &p); + pthread_mutex_unlock(&g_lock); + } else { + meow_img *im = thumb_load(tr.cover); + pthread_mutex_lock(&g_lock); + g_thumb_inflight[0] = '\0'; + if (im && !g_worker_stop) thumb_push_res_locked(&tr, im); + else if (im) meow_img_free(im); + pthread_mutex_unlock(&g_lock); + } } return NULL; } @@ -292,7 +374,7 @@ static void worker_start(void) { if (g_worker_on) return; g_worker_stop = 0; - if (pthread_create(&g_worker, NULL, thumb_worker, NULL) == 0) g_worker_on = 1; + if (pthread_create(&g_worker, NULL, lib_worker, NULL) == 0) g_worker_on = 1; } static void worker_stop(void) @@ -306,30 +388,57 @@ static void worker_stop(void) g_worker_on = 0; pthread_mutex_lock(&g_lock); - for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); - g_res_n = 0; - g_req_n = 0; - g_req_head = g_req_tail = 0; + for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); + g_tres_n = 0; + g_pres_n = 0; + g_treq_n = 0; + g_treq_head = g_treq_tail = 0; + g_pq_n = 0; + g_pq_head = g_pq_tail = 0; + g_probe_inflight[0] = '\0'; + g_thumb_inflight[0] = '\0'; pthread_mutex_unlock(&g_lock); } -/* 请求缩略图:bind 只投递,不阻塞首绘/滚动。 */ +/* 探测请求:只投递(有界队列 + 去重),不做 IO。 */ +static void probe_enqueue(int idx, const char *path, int is_archive) +{ + if (!g_worker_on || !path || !path[0]) return; + pthread_mutex_lock(&g_lock); + if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } + if (strcmp(g_probe_inflight, path) == 0) { pthread_mutex_unlock(&g_lock); return; } + for (int i = 0; i < g_pq_n; i++) { + int p = (g_pq_head + i) % MR_PQ_MAX; + if (strcmp(g_pq[p].path, path) == 0) { pthread_mutex_unlock(&g_lock); return; } + } + if (g_pq_n >= MR_PQ_MAX) { pthread_mutex_unlock(&g_lock); return; } + g_pq[g_pq_tail].gen = g_gen; + g_pq[g_pq_tail].idx = idx; + g_pq[g_pq_tail].is_archive = is_archive; + snprintf(g_pq[g_pq_tail].path, sizeof g_pq[g_pq_tail].path, "%s", path); + g_pq_tail = (g_pq_tail + 1) % MR_PQ_MAX; + g_pq_n++; + pthread_cond_signal(&g_cv); + pthread_mutex_unlock(&g_lock); +} + +/* 缩略图请求:bind 只投递,不阻塞首绘/滚动。 */ static void thumb_enqueue(int idx, const char *cover) { if (!g_worker_on || !cover || !cover[0]) return; pthread_mutex_lock(&g_lock); if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } - if (strcmp(g_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } - for (int i = 0; i < g_req_n; i++) { - int p = (g_req_head + i) % MR_REQ_MAX; - if (strcmp(g_req[p].cover, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + if (strcmp(g_thumb_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + for (int i = 0; i < g_treq_n; i++) { + int p = (g_treq_head + i) % MR_TREQ_MAX; + if (strcmp(g_treq[p].cover, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } } - if (g_req_n >= MR_REQ_MAX) { pthread_mutex_unlock(&g_lock); return; } - g_req[g_req_tail].gen = g_gen; - g_req[g_req_tail].idx = idx; - snprintf(g_req[g_req_tail].cover, sizeof g_req[g_req_tail].cover, "%s", cover); - g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; - g_req_n++; + if (g_treq_n >= MR_TREQ_MAX) { pthread_mutex_unlock(&g_lock); return; } + g_treq[g_treq_tail].gen = g_gen; + g_treq[g_treq_tail].idx = idx; + snprintf(g_treq[g_treq_tail].cover, sizeof g_treq[g_treq_tail].cover, "%s", cover); + g_treq_tail = (g_treq_tail + 1) % MR_TREQ_MAX; + g_treq_n++; pthread_cond_signal(&g_cv); pthread_mutex_unlock(&g_lock); } @@ -360,38 +469,38 @@ static int thumb_put(int idx, meow_img *im) } /* ============================================================ */ -/* 列表虚拟化数据源 */ +/* 行数据源(src2:每帧按需取可见行;零 IO,懒算请求只入队) */ /* ============================================================ */ -static int lib_count(void *ctx) -{ - (void)ctx; - return g_lib.count; -} - -static void lib_bind(int idx, meow_lv_item *item, void *ctx) +static int lib_src(int idx, meow_list_item *item, void *ctx) { (void)ctx; memset(item, 0, sizeof *item); - if (idx < 0 || idx >= g_lib.count) return; - mr_library_entry *e = &g_lib.items[idx]; - item->title = e->name; - item->subtitle = e->subtitle; - item->right = MEOW_LIST_RIGHT_ARROW; - item->enabled = 1; - item->user = e; - meow_img *im = thumb_find(idx); + mr_library_row row; + if (!mr_library_row_at(&g_lib, idx, &row)) return 0; + item->title = row.title; + item->subtitle = row.subtitle; + item->enabled = row.disabled ? 0 : 1; + if (row.disabled) return 1; + + int i = row.index; + mr_library_entry *e = &g_lib.items[i]; + item->right = row.is_book ? MEOW_LIST_RIGHT_NONE : MEOW_LIST_RIGHT_ARROW; + + meow_img *im = thumb_find(i); if (im) { item->icon_kind = MEOW_LIST_ICON_IMG; item->icon = im; } else { - item->icon_kind = e->has_images ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; + item->icon_kind = row.is_book ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; /* 文件夹册用缩略图源图;档案册没有图片文件,用档案路径(worker 解压首图)。 */ const char *src = NULL; if (e->cover[0]) src = e->cover; - else if (e->has_images && mr_path_is_archive(e->path)) src = e->path; - if (src) thumb_enqueue(idx, src); + else if (row.is_book && row.is_archive) src = e->path; + if (src) thumb_enqueue(i, src); } + if (e->probe_state == MR_LIB_PENDING) probe_enqueue(i, e->path, e->is_archive); + return 1; } /* ============================================================ */ @@ -402,6 +511,14 @@ static int lib_top_y(void) return meow_ui_statusbar_height() + MR_TOPAPPBAR_H; } +static void hint_place(void) +{ + g_hint_x = 0; + g_hint_y = lib_top_y(); + g_hint_w = meow_ui_width(); + g_hint_h = MR_HINT_H; +} + static void list_rebuild(void) { if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } @@ -412,16 +529,16 @@ static void list_rebuild(void) g_list_h = meow_ui_height() - g_list_y; if (g_list_h < 1) g_list_h = 1; - g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, 0, NULL, NULL, - MEOW_LIST_SEL_NONE); + int rows = g_lib.count + (g_lib.truncated ? 1 : 0); + g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, rows, + lib_src, NULL, MEOW_LIST_SEL_NONE); if (!g_lv) return; - meow_listview_set_vsource(g_lv, lib_count, lib_bind, NULL); meow_listview_set_item_renderer(g_lv, 2, 1, 1); meow_listview_set_item_tier(g_lv, 2); /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ meow_listview_set_container(g_lv, 1); - meow_listview_set_empty_text(g_lv, "No folders or CBZ books"); + meow_listview_set_empty_text(g_lv, "空目录(无可进入的子目录)"); g_scroll = meow_scroll_wrap(g_lv, 1); if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); @@ -431,12 +548,15 @@ static void update_topbar(void) { if (!g_tb) return; const char *title = "图库"; - if (strcmp(g_cur_dir, MR_ROOT_DIR) != 0) { + if (strcmp(g_cur_dir, "/") == 0) { + title = "文件系统 /"; + } else if (strcmp(g_cur_dir, g_root_dir) != 0) { const char *slash = strrchr(g_cur_dir, '/'); if (slash && slash[1]) title = slash + 1; } meow_topappbar_set_title(g_tb, title); - meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, MR_ROOT_DIR) != 0); + /* 可一路向上到文件系统 '/':到 '/' 才隐藏返回(此时左缘返回=退出 App)。 */ + meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, "/") != 0); } static void list_invalidate(void) @@ -444,61 +564,179 @@ static void list_invalidate(void) meow_ui_invalidate(g_list_x, g_list_y, g_list_w, g_list_h); } -/* 目录切换:作废旧异步结果、释放内存缩略图、重新扫描、重建列表(新池强制重新 bind)。 */ +static void hint_invalidate(void) +{ + meow_ui_invalidate(g_hint_x, g_hint_y, g_hint_w, g_hint_h); +} + +static void show_hint(const char *msg) +{ + snprintf(g_hint, sizeof g_hint, "%s", msg ? msg : ""); + g_hint_until = meow_ui_time_ms() + MR_HINT_MS; + hint_place(); + hint_invalidate(); +} + +/* 顶部横幅绘制物:只在提示有效且本页 active 时输出(custom 绘制物无 owner 闸门,必须自守)。 */ +static void library_draw_hint(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_LIBRARY) return; + if (!g_hint[0]) return; + + const meow_theme *th = meow_theme_get(); + int x = g_hint_x, y = g_hint_y, w = g_hint_w, h = g_hint_h; + if (w < 40 || h < 20) return; + meow_ui_fill_rect(x + 8, y + 4, w - 16, h - 8, th->surfaceContainerHighest); + meow_ui_draw_rect(x + 8, y + 4, w - 16, h - 8, th->error); + int ty = y + (h - meow_ui_text_height(th->font_body)) / 2; + meow_ui_text(x + 20, ty, g_hint, th->font_body, th->error); +} + +/* 目录切换:作废旧异步结果、释放内存缩略图、重建列表(新池强制重新取行)。 */ static void reset_async(void) { pthread_mutex_lock(&g_lock); g_gen++; - g_req_n = 0; - g_req_head = g_req_tail = 0; - for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); - g_res_n = 0; + g_pq_n = 0; + g_pq_head = g_pq_tail = 0; + g_treq_n = 0; + g_treq_head = g_treq_tail = 0; + g_pres_n = 0; + for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); + g_tres_n = 0; + g_probe_inflight[0] = '\0'; + g_thumb_inflight[0] = '\0'; pthread_mutex_unlock(&g_lock); } -/* 书库扫描的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ -static int cbz_archive_pages(const char *path, void *ud) +/* 目录切换:成功 1;不可读/不存在 0(提示;首次加载时仍建空列表)。 */ +static int load_dir(const char *dir) { - (void)ud; - return mr_zip_count_pages(path); -} + /* dir 可能指向 g_lib 里的行路径(点击进入子目录):先拷到本地, + * 避免切换后 mr_library_free 释放 g_lib,dir 变成悬垂指针。 */ + char target[MR_PATH_MAX]; + if (!dir || !dir[0] || strlen(dir) >= sizeof target) { + show_hint("路径无效"); + return 0; + } + snprintf(target, sizeof target, "%s", dir); + + mr_library next; + memset(&next, 0, sizeof next); + + uint64_t t0 = meow_ui_time_ms(); + int n = mr_library_scan_ex(&next, target, MR_LIB_MAX_DEPTH, + cbz_archive_pages, NULL); + uint64_t ms = meow_ui_time_ms() - t0; + if (n < 0) { + mr_library_free(&next); + const char *slash = strrchr(target, '/'); + const char *name = (slash && slash[1]) ? slash + 1 : target; + char msg[200]; + snprintf(msg, sizeof msg, "无法读取目录:%s", name); + show_hint(msg); + meow_log(MEOW_LOG_WARN, "library scan failed: %s", target); + /* 启动首帧根目录不可读:仍建一个空列表,页面可交互(返回/回根/上溯)。 */ + if (!g_lv) { + mr_library_free(&g_lib); + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); + reset_async(); + thumbs_free(); + list_rebuild(); + update_topbar(); + } + return 0; + } + if (ms > 120) + meow_log(MEOW_LOG_WARN, "library scan slow: %s %llums (%d rows)", + target, (unsigned long long)ms, n); + + mr_library_free(&g_lib); + g_lib = next; + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); -static void load_dir(const char *dir) -{ - snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); reset_async(); thumbs_free(); - if (mr_library_scan_ex(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH, - cbz_archive_pages, NULL) < 0) { - mr_library_free(&g_lib); - meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir); - } list_rebuild(); update_topbar(); g_drag = 0; g_dragged = 0; if (g_ctrl.active) meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + return 1; } int library_at_root(void) { - return strcmp(g_cur_dir, MR_ROOT_DIR) == 0; + return strcmp(g_cur_dir, g_root_dir) == 0; } int library_go_up(void) { - if (library_at_root()) return 0; + if (strcmp(g_cur_dir, "/") == 0) return 0; /* 到文件系统根:交给 app.c 退出 */ char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; - /* 不越过书库根(父级仍须在根目录之下或等于根)。 */ - if (strcmp(parent, MR_ROOT_DIR) != 0 && - strncmp(parent, MR_ROOT_DIR "/", sizeof(MR_ROOT_DIR)) != 0) - snprintf(parent, sizeof parent, "%s", MR_ROOT_DIR); - load_dir(parent); + if (!load_dir(parent)) { + /* 父目录不可读 → 提示并停留。特例:父目录是文件系统 "/" 时仍切到 "/" 的空列表, + * 让下一次返回=退出 App(Android 沙箱可能读不了 "/",否则会卡在最后一个可读目录)。 */ + if (strcmp(parent, "/") == 0) { + mr_library_free(&g_lib); + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", parent); + reset_async(); + thumbs_free(); + list_rebuild(); + update_topbar(); + if (g_ctrl.active) + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + } + return 1; + } return 1; } +/* TopAppBar「TF卡」:回到书库根(已在根则回到列表顶部)。 */ +static void library_go_tf_root(void) +{ + if (strcmp(g_cur_dir, g_root_dir) == 0) { + if (g_scroll) meow_scroll_stop(g_scroll); + if (g_lv) meow_listview_set_scroll(g_lv, 0); + list_invalidate(); + return; + } + load_dir(g_root_dir); +} + +/* 点击行:先确保懒判定完成(单层探测,通常已在缓存里),书=进阅读器,否则进目录。 */ +static void library_open_entry(int idx) +{ + if (idx < 0 || idx >= g_lib.count) return; + mr_library_entry *e = &g_lib.items[idx]; + + if (e->probe_state == MR_LIB_PENDING) { + mr_library_probe p; + if (mr_library_probe_path(e->path, e->is_archive, cbz_archive_pages, NULL, &p) == 0) + mr_library_apply_probe(&g_lib, idx, &p); + else + mr_library_mark_error(&g_lib, idx); + list_invalidate(); + } + if (e->probe_state == MR_LIB_FAILED) { + char msg[200]; + snprintf(msg, sizeof msg, "无法读取:%s", e->name); + show_hint(msg); + return; + } + if (e->has_images == 1) { mr_reader_open_dir(e->path); return; } + if (e->is_archive) { /* 档案册但没有图片页(坏档/空档):不导航 */ + char msg[200]; + snprintf(msg, sizeof msg, "无法阅读(档内没有图片页):%s", e->name); + show_hint(msg); + return; + } + load_dir(e->path); +} + /* ============================================================ */ /* 事件 */ /* ============================================================ */ @@ -511,14 +749,15 @@ void library_on_tap(int x, int y) if (g_tb) { int hit = meow_topappbar_hit(g_tb, x, y); if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (g_tb_home_idx >= 0 && hit == g_tb_home_idx) { library_go_tf_root(); return; } if (hit >= 0) { meow_view_page_push(ACT_SETTINGS); return; } } if (!g_lv) return; int idx = meow_listview_hit(g_lv, x, y); - if (idx < 0 || idx >= g_lib.count) return; - mr_library_entry *e = &g_lib.items[idx]; - if (e->has_images) mr_reader_open_dir(e->path); - else load_dir(e->path); + if (idx < 0) return; + mr_library_row row; + if (!mr_library_row_at(&g_lib, idx, &row) || row.disabled) return; + library_open_entry(row.index); } void library_on_swipe(int dir, int dist) @@ -567,19 +806,47 @@ void library_on_touch_move(int x, int y) list_invalidate(); } -/* 16ms tick:收下后台解码好的缩略图并局部补位。 */ +/* 16ms tick:收下后台探测/缩略图结果并局部补位;提示横幅到期自动清除。 */ void library_on_tick(void) { if (!g_ctrl.active) return; - thumb_res got[MR_RES_MAX]; - int n = 0; + + if (g_hint[0] && meow_ui_time_ms() >= g_hint_until) { + g_hint[0] = '\0'; + hint_invalidate(); + } + + probe_res pres[MR_PRES_MAX]; + int pn = 0; pthread_mutex_lock(&g_lock); - n = g_res_n; - for (int i = 0; i < n; i++) got[i] = g_res[i]; - g_res_n = 0; + pn = g_pres_n; + for (int i = 0; i < pn; i++) pres[i] = g_pres[i]; + g_pres_n = 0; pthread_mutex_unlock(&g_lock); int changed = 0; + for (int i = 0; i < pn; i++) { + probe_res *r = &pres[i]; + if (r->gen != g_gen || r->idx < 0 || r->idx >= g_lib.count) continue; + mr_library_entry *e = &g_lib.items[r->idx]; + if (strcmp(e->path, r->path) != 0) continue; + if (r->ok) { + if (mr_library_apply_probe(&g_lib, r->idx, &r->probe) && e->cover[0]) + thumb_enqueue(r->idx, e->cover); + } else { + mr_library_mark_error(&g_lib, r->idx); + } + changed = 1; + } + + thumb_res got[MR_TRES_MAX]; + int n = 0; + pthread_mutex_lock(&g_lock); + n = g_tres_n; + for (int i = 0; i < n; i++) got[i] = g_tres[i]; + g_tres_n = 0; + pthread_mutex_unlock(&g_lock); + for (int i = 0; i < n; i++) { if (got[i].gen == g_gen && got[i].idx >= 0 && got[i].idx < g_lib.count && !thumb_find(got[i].idx) && thumb_put(got[i].idx, got[i].img)) @@ -605,10 +872,15 @@ static void library_page_create(meow_view *v, void *ctx) int sb = meow_ui_statusbar_height(); g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); if (g_tb) { + g_tb_home_idx = meow_topappbar_add_action(g_tb, "TF卡"); int ai = meow_topappbar_add_action(g_tb, ""); if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_SETTINGS); } - load_dir(MR_ROOT_DIR); + + hint_place(); + g_hint_id = meow_draw_custom_register(MEOW_Z_TOP, g_hint_x, g_hint_y, + g_hint_w, g_hint_h, library_draw_hint, NULL); + load_dir(g_root_dir); } static void library_page_enter(meow_view *v, void *ctx) @@ -632,6 +904,7 @@ static void library_page_enter(meow_view *v, void *ctx) g_list_w = meow_ui_width(); g_list_h = h; } + hint_place(); g_drag = 0; g_dragged = 0; update_topbar(); @@ -652,11 +925,13 @@ static void library_page_destroy(meow_view *v, void *ctx) { (void)v; (void)ctx; worker_stop(); + if (g_hint_id) { meow_draw_unregister(g_hint_id); g_hint_id = 0; } if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } thumbs_free(); mr_library_free(&g_lib); + mr_library_probe_cache_clear(); } meow_view *library_view_create(void) @@ -669,3 +944,62 @@ meow_view *library_view_create(void) }; return meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); } + +#ifdef MR_LIBRARY_UI_TEST_HOOKS +/* 宿主集成测试接缝(只在 -DMR_LIBRARY_UI_TEST_HOOKS 编译时存在;不进交付二进制)。 */ +int library_test_load(const char *dir) +{ + return load_dir(dir); +} + +void library_test_set_root(const char *root) +{ + if (root && root[0]) snprintf(g_root_dir, sizeof g_root_dir, "%s", root); +} + +const char *library_test_root(void) +{ + return g_root_dir; +} + +const char *library_test_dir(void) +{ + return g_cur_dir; +} + +void library_test_set_dir(const char *dir) +{ + if (dir && dir[0]) snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); +} + +int library_test_rows(void) +{ + return g_lib.count + (g_lib.truncated ? 1 : 0); +} + +int library_test_src(int idx, meow_list_item *item) +{ + return lib_src(idx, item, NULL); +} + +int library_test_hint_active(void) +{ + return g_hint[0] != '\0'; +} + +const char *library_test_hint(void) +{ + return g_hint; +} + +int library_test_go_root(void) +{ + library_go_tf_root(); + return strcmp(g_cur_dir, g_root_dir) == 0; +} + +int library_test_go_up(void) +{ + return library_go_up(); +} +#endif From 66e598cb622f9520423ca19195f6ee5fb93d9478 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 12:31:46 +0800 Subject: [PATCH 13/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=20LRU=20=E8=B7=B3=E8=BF=87=E8=A7=A3?= =?UTF-8?q?=E5=8E=8B=E9=A1=B5=E7=BC=93=E5=AD=98=EF=BC=88M11=20P2-1=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=EF=BC=89=EF=BC=8C=E8=A1=A5=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=A9=BA=E5=88=97=E8=A1=A8/prune=20=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=88M13=20=E8=AF=84=E5=AE=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/library/library.c cache_prune:rebase 时丢失了 M11 c775f85 的必修保护, 恢复 `if (mr_cache_is_page_name(de->d_name)) continue;`(在 .bmp 过滤之前)。 解压页缓存与缩略图同目录,mrpage_* 归 page_cache 自己的 64 页/32MiB LRU 管理; 否则档案页被缩略图 LRU 统计并删除 → 每翻页重解压,且「touch 命中→解码」窗口内 可能被删导致解码失败。 - 回归测试(MR_LIBRARY_UI_TEST_HOOKS 接缝):test_browse_ui_host 新增 U6—— 预置 1 个最旧 mrpage_*.bmp + 65 个较新缩略图,调用 library_test_cache_prune(), 断言 mrpage_* 保留且不计入 64 张额度、缩略图被裁到 64;已验证去掉修复时该用例 会失败(防止再次在 rebase 中丢失)。 - 顺手补齐/校正: ① test_browse_ui_host 的启动路径改为 create 前就把根指向不存在的目录,真实覆盖 「启动根不可读仍建空列表」(断言 has_list/rows==0/横幅/当前目录),不再只是声明; ② tuku_internal.h 的 library_at_root 注释改为「书库根(生产=MR_ROOT_DIR,测试可改根)」。 - 复验:run_tests.sh 全 PASS(73/69/90/18/108/19/85 + settings 集成 + browse UI 集成 + zip 106 + cbz 325);run_webtoon_tests.sh 全 PASS(76/36/71/57 + 集成); sdk/build_sdk.sh + build_tuku.sh 零警告 MIPS32r2 静态 ELF;UI host 测试 ASan/UBSan 全绿。 --- tuku/test/test_browse_ui_host.c | 99 ++++++++++++++++++++++++++++++++- tuku/tuku_internal.h | 2 +- tuku/ui/library/library.c | 25 +++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index b1bbe73..f278098 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -6,7 +6,10 @@ * U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」; * U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出); * 「TF卡」回根动作回到书库根; - * U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅。 + * U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅; + * U5 启动根不可读:create 时仍建空列表(不是只有横幅); + * U6 缩略图 LRU 跳过解压页缓存(M11 P2-1 回归):cache_prune 只统计/删除缩略图, + * mrpage_*(page_cache 的解压页)必须保留且不计入 64 张额度。 * * 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩; * 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以 @@ -19,6 +22,8 @@ #include #include #include +#include +#include #include #include @@ -34,6 +39,10 @@ int library_test_hint_active(void); const char *library_test_hint(void); int library_test_go_root(void); int library_test_go_up(void); +int library_test_has_list(void); +const char *library_test_cache_dir(void); +int library_test_cache_on(void); +void library_test_cache_prune(void); static int g_fail; #define CHECK(cond, ...) do { \ @@ -114,6 +123,33 @@ static int row_index_for(const char *name) return -1; } +static void set_mtime(const char *path, time_t t) +{ + struct timespec ts[2]; + ts[0].tv_sec = t; + ts[0].tv_nsec = 0; + ts[1] = ts[0]; + utimensat(AT_FDCWD, path, ts, 0); +} + +/* 统计缓存目录里的缩略图(*.bmp 且非 mrpage_*)与解压页(mrpage_*.bmp)。 */ +static int count_bmp(const char *dir, int *pages) +{ + DIR *d = opendir(dir); + if (!d) return -1; + int thumbs = 0, pg = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + size_t len = strlen(de->d_name); + if (len <= 4 || strcmp(de->d_name + len - 4, ".bmp") != 0) continue; + if (strncmp(de->d_name, "mrpage_", 7) == 0) pg++; + else thumbs++; + } + closedir(d); + if (pages) *pages = pg; + return thumbs; +} + static void wait_ready(int ms) { for (int t = 0; t < ms / 10; t++) { @@ -130,6 +166,55 @@ static void wait_ready(int ms) } } +/* U6:缩略图 LRU 必须跳过解压页缓存(M11 P2-1 回归;MR_CACHE_MAX_FILES=64)。 */ +static void test_cache_prune_skips_pages(void) +{ + const char *dir = library_test_cache_dir(); + CHECK(library_test_cache_on() && dir && dir[0], "thumbnail cache enabled: %s", + dir ? dir : ""); + if (!dir || !dir[0]) return; + + /* 清空之前可能写入的 bmp(本测试独占 MEOW_DATA_DIR)。 */ + DIR *d = opendir(dir); + if (d) { + struct dirent *de; + while ((de = readdir(d)) != NULL) { + size_t len = strlen(de->d_name); + if (len > 4 && strcmp(de->d_name + len - 4, ".bmp") == 0) { + char full[MR_PATH_MAX + 96]; + snprintf(full, sizeof full, "%s/%s", dir, de->d_name); + unlink(full); + } + } + closedir(d); + } + + char page[MR_PATH_MAX + 96], thumb[MR_PATH_MAX + 96]; + snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", dir); + CHECK(touch_file(page) == 0, "create page cache file"); + set_mtime(page, time(NULL) - 7200); /* 最旧:若被误删一定先删它 */ + + time_t base = time(NULL) - 3600; + for (int i = 0; i < 65; i++) { /* 65 张缩略图 > MR_CACHE_MAX_FILES */ + snprintf(thumb, sizeof thumb, "%s/%016x_%ld_%d.bmp", dir, + (unsigned)i, (long)(base + i), i); + CHECK(touch_file(thumb) == 0, "create thumbnail %d", i); + set_mtime(thumb, base + i); + } + + int pages_before = 0; + CHECK(count_bmp(dir, &pages_before) == 65, "precondition 65 thumbnails"); + CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before); + + library_test_cache_prune(); + + int pages_after = 0; + int thumbs_after = count_bmp(dir, &pages_after); + CHECK(access(page, R_OK) == 0, "mrpage_* survives thumbnail LRU"); + CHECK(pages_after == 1, "mrpage_* not counted as thumbnail (got %d)", pages_after); + CHECK(thumbs_after == 64, "thumbnails trimmed to 64 (got %d)", thumbs_after); +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -157,7 +242,17 @@ int main(void) meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + /* U5:先指向一个不存在的根,再 create —— 真实走「启动时根目录不可读」分支。 */ + const char *bad_root = "/no/such/tuku/m13_root"; + library_test_set_root(bad_root); + CHECK(strcmp(library_test_root(), bad_root) == 0, "bad root installed"); meow_view *lib = library_view_create(); + CHECK(library_test_hint_active(), "startup unreadable root shows hint: %s", + library_test_hint()); + CHECK(library_test_rows() == 0, "startup empty list rows=%d", library_test_rows()); + CHECK(library_test_has_list(), "startup built listview for empty list"); + CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir()); + library_test_set_root(root); CHECK(strcmp(library_test_root(), root) == 0, "test root installed"); meow_view_page_push(ACT_LIBRARY); @@ -260,6 +355,8 @@ int main(void) printf(" SKIP unreadable-dir case (running as root)\n"); } + test_cache_prune_skips_pages(); + meow_view_free(lib); meow_ui_shutdown(); rm_rf(data); diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h index dffae61..b23d6d5 100644 --- a/tuku/tuku_internal.h +++ b/tuku/tuku_internal.h @@ -37,7 +37,7 @@ typedef struct mr_page_ctrl { /* ---- 书库页(ui/library/library.c) ---- */ meow_view *library_view_create(void); -int library_at_root(void); /* 1=当前在 MR_ROOT_DIR */ +int library_at_root(void); /* 1=当前在书库根(生产=MR_ROOT_DIR,宿主测试可改根) */ int library_go_up(void); /* 上溯父目录:1=已上移/提示停留,0=已在文件系统 "/"(app.c 据此退出) */ void library_on_tap(int x, int y); void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 0332796..a58f8a3 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -186,6 +186,10 @@ static void cache_prune(void) time_t oldest_t = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { + /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 + * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 + * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ + if (mr_cache_is_page_name(de->d_name)) continue; const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".bmp") != 0) continue; char full[MR_PATH_MAX + 96]; @@ -977,6 +981,11 @@ int library_test_rows(void) return g_lib.count + (g_lib.truncated ? 1 : 0); } +int library_test_has_list(void) +{ + return g_lv != NULL; +} + int library_test_src(int idx, meow_list_item *item) { return lib_src(idx, item, NULL); @@ -1002,4 +1011,20 @@ int library_test_go_up(void) { return library_go_up(); } + +/* 缩略图 LRU 接缝(M11 P2-1 回归):指向当前缓存目录并同步跑一次 prune。 */ +const char *library_test_cache_dir(void) +{ + return g_cache_dir; +} + +int library_test_cache_on(void) +{ + return g_cache_on; +} + +void library_test_cache_prune(void) +{ + cache_prune(); +} #endif From 3cbcaaaac172f17b81b24b180582acc1c45c1751 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 13:03:20 +0800 Subject: [PATCH 14/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=BF=94=E5=9B=9E=E8=AF=AD=E4=B9=89=E4=B8=8E=E6=89=93?= =?UTF-8?q?=E5=BC=80=E5=86=8C=E8=B5=84=E6=BA=90=E8=B7=AF=E5=BE=84=EF=BC=88?= =?UTF-8?q?M15=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 返回手势:书库列表排除左缘 24px 系统返回带并清空残留拖拽态;library_go_up 到 TF 根返回 0(app.c 退出 App),不再上溯系统目录;顶栏返回改为显式 library_go_parent(访问放开,可越过 TF 根到 /)。lock 页继续自行消费返回。 - 打开册:缩略图源图改用预算解码(meow_img_load_file_dim,峰值数十 MB→~1MB); 离开书库页停止后台探测/缩略图线程,避免与 open_book 争内存/FD;打开失败按 errno 记日志,ENOMEM/EMFILE/ENFILE/EAGAIN/EINTR 自动重试一次;修 open_book 自拷贝 snprintf 的未定义行为。 - 测试:test_browse_ui_host 增加 TF 根退出/顶栏上级/左缘不拖拽断言;新增 test_open_book_host(封面图+含图子目录的父目录 → reader 打开的完整 UI 链路)。 --- tuku/app.c | 4 +- tuku/test/run_tests.sh | 20 +++ tuku/test/test_browse_ui_host.c | 26 ++++ tuku/test/test_open_book_host.c | 241 ++++++++++++++++++++++++++++++++ tuku/tuku_internal.h | 3 +- tuku/ui/library/library.c | 65 +++++++-- tuku/ui/reader/reader.c | 31 +++- 7 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 tuku/test/test_open_book_host.c diff --git a/tuku/app.c b/tuku/app.c index 893a0d5..f4dfb68 100644 --- a/tuku/app.c +++ b/tuku/app.c @@ -4,7 +4,9 @@ * - main 只做 meow_ui_init → 建 view → meow_ui_run → 清理;退出走 meow_ui_request_exit; * - 页面切换统一走 meow_view_page_push/pop(activity 栈 + on_enter/on_leave 唯一入口); * - 物理键不映射返回/确认(on_key 仅日志);屏底上滑由 SDK 系统手势接管,App 不拦截; - * - 左缘返回:书库内逐级上移,根目录才退出。 + * - 左缘返回:reader/settings=pop;lock 页自行消费;书库逐级上移, + * **TF 根=退出 App**(系统返回语义,不再上溯系统目录);TF 根以外的上级浏览 + * 走顶栏显式「上级」动作。 * * 主题同步:注册 meow_theme_add_listener;明暗/风格族切换时更新页面底色并整帧 invalidate, * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 4d75e14..92debcd 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -11,6 +11,8 @@ # - test_settings_lock_host(可选):设置页/锁屏与 SDK view/activity/widget 接线的宿主集成 # 验证(owner 归属 + 锁屏 SWIPE 拦截)。需要 SDK **源码树**(不只 meow_ui.a); # 交付环境只有库+头时自动 SKIP。 +# - test_open_book_host(可选):书库点击「封面图 + 含图子目录」的父目录 → 阅读器打开的 +# 完整 UI 链路回归(合成夹具,不读真实相册);同样需要 SDK 源码树。 # reader.c 的方向持久化只 include 配置/日志两个模块头;宿主测试用 test/settings_reader_stub.c # 顶替 meow_config_* 与 meow_log(所以下面各条都带 -I"$SDK" 与该存根)。 # logic/library.c 的探测缓存用 pthread 互斥锁 → 相关编译带 -pthread。 @@ -87,6 +89,24 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_browse_ui_host "$OUT"/test_browse_ui_host + + # 点击父目录(封面图 + 含图子目录)→ 阅读器打开:完整 UI 链路回归。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST \ + -DMR_LIBRARY_UI_TEST_HOOKS -I. -I"$SDK" -I"$SDK/include" \ + test/test_open_book_host.c ui/library/library.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_open_book_host + "$OUT"/test_open_book_host else echo "SKIP test_settings_lock_host/test_browse_ui_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index f278098..6ac52f6 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -39,6 +39,8 @@ int library_test_hint_active(void); const char *library_test_hint(void); int library_test_go_root(void); int library_test_go_up(void); +int library_test_go_parent(void); +int library_test_drag_active(void); int library_test_has_list(void); const char *library_test_cache_dir(void); int library_test_cache_on(void); @@ -336,6 +338,30 @@ int main(void) CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)"); CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture"); + /* ---- U3b:系统返回语义 = TF 根退出 App;顶栏「上级」保留越界浏览 ---- */ + CHECK(library_test_load(root) == 1, "load fixture root"); + CHECK(library_test_go_up() == 0, "go_up at TF root returns 0 (app exits)"); + CHECK(strcmp(library_test_dir(), root) == 0, "still at TF root after exit gesture"); + + /* 顶栏显式「上级」:访问放开,TF 根 → 其父目录(fixture root 的 parent = data)。 */ + CHECK(library_test_go_parent() == 1, "explicit up from TF root consumed"); + CHECK(strcmp(library_test_dir(), data) == 0, + "explicit up goes above TF root (%s)", library_test_dir()); + CHECK(library_test_load(root) == 1, "back to fixture root"); + + /* ---- U3c:左缘 24px 系统返回手势带不启动列表拖拽 ---- */ + int row_y = list_top + 28; + library_on_touch_down(120, row_y); + CHECK(library_test_drag_active() == 1, "touch inside list starts drag"); + library_on_touch_down(5, row_y); + CHECK(library_test_drag_active() == 0, "left-edge touch does not start drag"); + library_on_touch_down(23, row_y); + CHECK(library_test_drag_active() == 0, "left-edge (23px) does not start drag"); + library_on_touch_down(24, row_y); + CHECK(library_test_drag_active() == 1, "just outside left band starts drag"); + library_on_touch_down(120, meow_ui_height() - 4); + CHECK(library_test_drag_active() == 0, "bottom hotzone does not start drag"); + /* ---- U4:不可读目录 → 停留 + 提示 ---- */ CHECK(library_test_load(root) == 1, "back to fixture root"); char noread[MR_PATH_MAX]; diff --git a/tuku/test/test_open_book_host.c b/tuku/test/test_open_book_host.c new file mode 100644 index 0000000..c2b0529 --- /dev/null +++ b/tuku/test/test_open_book_host.c @@ -0,0 +1,241 @@ +/* test_open_book_host.c — 书库点击 → 阅读器打开的宿主集成回归(需 SDK 源码树)。 + * + * 复现用户报告:「父文件夹有封面图片、子文件夹也有图片,点开父文件夹报无法打开此册」。 + * 用合成夹具(真实 BMP 当封面/子图,不读用户相册)走完整链路: + * library 列表 → 行点击 → mr_reader_open_dir → reader on_enter → mr_book_open_auto + * 并断言父目录以「书」打开(count=1)且没有进入 open_err。 + * + * 另覆盖:封面图很大时打开仍成功(打开只枚举,不解码);同目录二次打开/离开重开。 + * + * 编译:见 test/run_tests.sh(与 test_mode_switch_host 同口径)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" + +#include +#include +#include +#include +#include +#include + +/* ui/library/library.c 的测试接缝 */ +int library_test_load(const char *dir); +void library_test_set_root(const char *root); +const char *library_test_dir(void); +int library_test_rows(void); +int library_test_src(int idx, meow_list_item *item); +int library_test_worker_on(void); + +/* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */ +int mr_reader_ui_mode(void); +int mr_reader_ui_page(void); +int mr_reader_ui_count(void); +int mr_reader_ui_open_err(void); + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +void mr_nav_report(void) {} + +static void put16(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; + p[1] = (unsigned char)(v >> 8); +} +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +static int write_bmp24(const char *path, int w, int h) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void wait_ready(int ms) +{ + for (int t = 0; t < ms / 10; t++) { + library_on_tick(); + int ready = 1; + int rows = library_test_rows(); + for (int i = 0; i < rows; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; } + } + if (ready) return; + usleep(10000); + } +} + +static int row_index_for(const char *name) +{ + int rows = library_test_rows(); + for (int i = 0; i < rows; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.title && strcmp(it.title, name) == 0) return i; + } + return -1; +} + +/* 用真实点击坐标点某一行(列表顶端 = 状态栏 + 56px 顶栏,行高 56)。 */ +static void tap_row(const char *name) +{ + int idx = row_index_for(name); + int list_top = meow_ui_statusbar_height() + 56; + library_on_tap(120, list_top + idx * 56 + 28); +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_open_host_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX], parent[MR_PATH_MAX], sub[MR_PATH_MAX], p[MR_PATH_MAX]; + snprintf(root, sizeof root, "%s/library", data); + snprintf(parent, sizeof parent, "%s/Pictures", root); + if (mkdir_p(parent) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + + /* 父目录:一张封面图 + 两个含图子目录(用户报告的结构)。 */ + snprintf(p, sizeof p, "%s/cover.jpg", parent); /* 扩展名即可;打开只枚举不解码 */ + FILE *f = fopen(p, "wb"); + if (!f) { printf("SKIP: cover write failed\n"); return 0; } + fputs("x", f); + fclose(f); + snprintf(sub, sizeof sub, "%s/2023", parent); + mkdir_p(sub); + snprintf(p, sizeof p, "%s/a.bmp", sub); + write_bmp24(p, 40, 60); + snprintf(sub, sizeof sub, "%s/2024", parent); + mkdir_p(sub); + snprintf(p, sizeof p, "%s/b.bmp", sub); + write_bmp24(p, 40, 60); + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + library_test_set_root(root); + meow_view *lib = library_view_create(); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + CHECK(library_test_load(root) == 1, "load fixture root"); + wait_ready(3000); + + int ip = row_index_for("Pictures"); + meow_list_item it; + CHECK(ip >= 0 && library_test_src(ip, &it), "Pictures row present"); + CHECK(it.subtitle && strcmp(it.subtitle, "1 page") == 0, + "Pictures probed as book with direct cover (subtitle=%s)", + it.subtitle ? it.subtitle : ""); + + /* 点击父目录 → 进阅读器;cover 是唯一直接子图 → count=1,不能是 open_err。 */ + tap_row("Pictures"); + CHECK(meow_ui_activity_active() == ACT_READER, "tap opens reader"); + CHECK(strcmp(mr_reader_dir(), parent) == 0, "reader dir=%s", mr_reader_dir()); + CHECK(mr_reader_ui_open_err() == 0, "parent folder opens without open_err"); + CHECK(mr_reader_ui_count() == 1, "parent book pages=%d (want 1)", + mr_reader_ui_count()); + CHECK(library_test_worker_on() == 0, + "library background decode stopped while reader is active"); + for (int i = 0; i < 30; i++) { usleep(10 * 1000); } + CHECK(mr_reader_ui_open_err() == 0, "still no open_err after decode tick"); + + /* 返回书库 → 后台线程恢复,再次点开(同目录二次打开 + 续读恢复)。 */ + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library"); + CHECK(library_test_worker_on() == 1, "library background decode restarted on return"); + tap_row("Pictures"); + CHECK(mr_reader_ui_open_err() == 0, "reopen parent without open_err"); + CHECK(mr_reader_ui_count() == 1, "reopen pages=%d", mr_reader_ui_count()); + meow_view_page_pop(); + + /* 封面是大图也不影响打开(打开只枚举文件名)。 */ + snprintf(p, sizeof p, "%s/cover.bmp", parent); + CHECK(write_bmp24(p, 400, 600) == 0, "write large cover"); + CHECK(library_test_load(root) == 1, "reload after cover change"); + wait_ready(3000); + tap_row("Pictures"); + CHECK(mr_reader_ui_open_err() == 0, "open with large cover has no open_err"); + CHECK(mr_reader_ui_count() == 2, "cover.jpg + cover.bmp pages=%d (want 2)", + mr_reader_ui_count()); + meow_view_page_pop(); + + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "library→reader open checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h index b23d6d5..4dcb027 100644 --- a/tuku/tuku_internal.h +++ b/tuku/tuku_internal.h @@ -38,7 +38,8 @@ typedef struct mr_page_ctrl { /* ---- 书库页(ui/library/library.c) ---- */ meow_view *library_view_create(void); int library_at_root(void); /* 1=当前在书库根(生产=MR_ROOT_DIR,宿主测试可改根) */ -int library_go_up(void); /* 上溯父目录:1=已上移/提示停留,0=已在文件系统 "/"(app.c 据此退出) */ +int library_go_up(void); /* 系统返回语义:逐级上移;TF 根/"/" 返回 0(app.c 据此退出) */ +int library_go_parent(void); /* 顶栏显式「上级」:访问放开,可越过 TF 根到 '/' */ void library_on_tap(int x, int y); void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ void library_on_touch_down(int x, int y); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index a58f8a3..2514112 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -6,8 +6,8 @@ * - 首帧只做**单层**枚举(logic/library.c:一次 opendir/readdir+lstat), * 不做任何递归「子树含图」探测;列表行 = 当前目录的直接子目录(M11 起还包括 * .cbz/.zip 档案册,与目录自然序混排); - * - 系统目录/空目录/无图目录都列出且可进入;可一路向上到文件系统 '/', - * 左缘返回由 app.c 逐级上溯、到 '/' 才退出 App; + * - 系统目录/空目录/无图目录都列出且可进入;左缘返回按系统语义逐级上移, + * **到 TF 根即退出 App**(不再上溯系统目录);TF 根以外的上级浏览走顶栏「上级」动作; * - 「直接含图目录=一本书」与档案册页数改为后台懒算:行仍在 PENDING 时副标题「…」, * 后台线程用 mr_library_probe_path(单层 + mtime/size 失效缓存)算出页数/首图, * on_tick 补位后只 invalidate 列表视口——首帧/滚动/命中判定都不读子目录/档案; @@ -41,6 +41,7 @@ #define MR_THUMB_MAX 24 #define MR_THUMB_DIM 96 +#define MR_THUMB_DECODE_DIM 256 /* 缩略图源图预算解码边长(峰值 ~1MB;整幅解码可达数十 MB) */ #define MR_TOPAPPBAR_H 56 #define MR_DEFAULT_ROW_H 56 #define MR_CACHE_MAX_FILES 64 @@ -294,7 +295,10 @@ static meow_img *thumb_load(const char *cover) } } - meow_img *im = meow_img_load_file(src); + /* 预算解码:封面可能是 12MP 手机照片,整幅解码峰值 ~48MB,会在低内存设备上 + * 挤掉随后 reader 打开所需的 opendir/malloc(用户报告「点开父文件夹=无法打开此册」)。 + * 缩略图最终只要 96px,先解到 ≤MR_THUMB_DECODE_DIM 再精修,视觉无损、峰值 ~1MB。 */ + meow_img *im = meow_img_load_file_dim(src, MR_THUMB_DECODE_DIM); if (!im) return NULL; meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); if (small) { meow_img_free(im); im = small; } @@ -559,7 +563,8 @@ static void update_topbar(void) if (slash && slash[1]) title = slash + 1; } meow_topappbar_set_title(g_tb, title); - /* 可一路向上到文件系统 '/':到 '/' 才隐藏返回(此时左缘返回=退出 App)。 */ + /* 顶栏「上级」是显式浏览动作(访问放开,可越过 TF 根上溯到 '/'); + * 左缘返回是系统返回语义,TF 根=退出 App(见 library_go_up)。到 '/' 才隐藏。 */ meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, "/") != 0); } @@ -640,7 +645,8 @@ static int load_dir(const char *dir) char msg[200]; snprintf(msg, sizeof msg, "无法读取目录:%s", name); show_hint(msg); - meow_log(MEOW_LOG_WARN, "library scan failed: %s", target); + meow_log(MEOW_LOG_WARN, "library scan failed: %s (errno=%d %s)", target, + errno, strerror(errno)); /* 启动首帧根目录不可读:仍建一个空列表,页面可交互(返回/回根/上溯)。 */ if (!g_lv) { mr_library_free(&g_lib); @@ -678,7 +684,17 @@ int library_at_root(void) int library_go_up(void) { - if (strcmp(g_cur_dir, "/") == 0) return 0; /* 到文件系统根:交给 app.c 退出 */ + /* 系统返回语义:TF 根 = 退出 App(app.c 收到 0 后 request_exit), + * 不再上溯系统目录;文件系统 "/" 同理。 */ + if (strcmp(g_cur_dir, g_root_dir) == 0) return 0; + if (strcmp(g_cur_dir, "/") == 0) return 0; + return library_go_parent(); +} + +/* 顶栏「上级」:显式浏览动作,访问放开——可越过 TF 根一路到 '/'。 */ +int library_go_parent(void) +{ + if (strcmp(g_cur_dir, "/") == 0) return 0; char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; if (!load_dir(parent)) { @@ -719,10 +735,13 @@ static void library_open_entry(int idx) if (e->probe_state == MR_LIB_PENDING) { mr_library_probe p; - if (mr_library_probe_path(e->path, e->is_archive, cbz_archive_pages, NULL, &p) == 0) + if (mr_library_probe_path(e->path, e->is_archive, cbz_archive_pages, NULL, &p) == 0) { mr_library_apply_probe(&g_lib, idx, &p); - else + } else { + meow_log(MEOW_LOG_WARN, "library probe failed: %s (errno=%d %s)", e->path, + errno, strerror(errno)); mr_library_mark_error(&g_lib, idx); + } list_invalidate(); } if (e->probe_state == MR_LIB_FAILED) { @@ -752,7 +771,7 @@ void library_on_tap(int x, int y) if (g_tb) { int hit = meow_topappbar_hit(g_tb, x, y); - if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (hit == MEOW_TOPBAR_BACK) { library_go_parent(); return; } if (g_tb_home_idx >= 0 && hit == g_tb_home_idx) { library_go_tf_root(); return; } if (hit >= 0) { meow_view_page_push(ACT_SETTINGS); return; } } @@ -788,8 +807,14 @@ void library_on_swipe(int dir, int dist) void library_on_touch_down(int x, int y) { if (!g_ctrl.active || !g_lv) return; + /* 每次按下都作废上一次手势的拖拽状态:热区/左缘提前返回时不能残留 g_drag, + * 否则系统返回手势的 move 仍会驱动列表滚动。 */ + g_drag = 0; + g_dragged = 0; /* 屏底 28px 是系统「上滑回桌面」热区:不从这里启动列表拖拽,避免拦截系统手势。 */ if (y >= meow_ui_height() - 28) return; + /* 左缘 24px 是系统返回手势带(与 reader 同口径):列表不启动拖拽,把滑动留给系统返回。 */ + if (x < 24) return; if (x < g_list_x || x >= g_list_x + g_list_w || y < g_list_y || y >= g_list_y + g_list_h) return; g_drag = 1; @@ -911,6 +936,7 @@ static void library_page_enter(meow_view *v, void *ctx) hint_place(); g_drag = 0; g_dragged = 0; + worker_start(); /* 离开时停止(见 leave),回页恢复探测/缩略图 */ update_topbar(); mr_nav_report(); meow_ui_paint_full(); @@ -923,6 +949,10 @@ static void library_page_leave(meow_view *v, void *ctx) g_drag = 0; g_dragged = 0; if (g_scroll) meow_scroll_stop(g_scroll); + /* 停止后台解码:进 reader 前释放/停止潜在的大图解码,避免与 open_book 的 + * opendir/malloc 抢内存(设备上「点开父文件夹=无法打开此册」的诱因之一)。 + * worker_stop 会 join,保证 page_push 的 on_enter 打开册时线程已退出。 */ + worker_stop(); } static void library_page_destroy(meow_view *v, void *ctx) @@ -1012,6 +1042,23 @@ int library_test_go_up(void) return library_go_up(); } +int library_test_go_parent(void) +{ + return library_go_parent(); +} + +/* 跟手拖拽是否在本次手势中被启动(左缘/屏底热区排除的回归观测口)。 */ +int library_test_drag_active(void) +{ + return g_drag; +} + +/* 后台探测/缩略图线程是否在跑(离开书库页应停止,回页恢复)。 */ +int library_test_worker_on(void) +{ + return g_worker_on; +} + /* 缩略图 LRU 接缝(M11 P2-1 回归):指向当前缓存目录并同步跑一次 prune。 */ const char *library_test_cache_dir(void) { diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index a00aea2..fd717d2 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -42,6 +42,7 @@ #include "logic/reader/page_cache.h" #include "logic/reader/nav.h" +#include #include #include #include @@ -79,6 +80,8 @@ static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册 static mr_book g_book; static int g_count; /* 0=空册/不可读 */ static int g_open_err; +static uint64_t g_open_retry_at; /* 资源类打开失败(ENOMEM/EMFILE…)的自动重试时刻 */ +static int g_open_retry_used; /* 1=本次进入已用过自动重试(避免反复重试) */ static int g_page; /* 翻页模式当前页;条漫模式=最近落位页 */ static int g_mode = MR_MODE_PAGED; @@ -1158,6 +1161,7 @@ static void open_book_reset_state(void) g_gen++; /* 作废旧册的异步结果 */ g_count = 0; g_open_err = 0; + g_open_retry_at = 0; g_page = 0; g_woff = 0; g_zoom_pct = 100; @@ -1179,7 +1183,9 @@ static int open_book(const char *dir, int start_page, int mode) { if (!dir || !dir[0]) return 0; open_book_reset_state(); - snprintf(g_dir, sizeof g_dir, "%s", dir); + /* reader_page_enter 传入的 dir 就是 g_dir:自拷贝是 snprintf 的未定义行为, + * 其它调用(册间衔接)才需要复制。 */ + if (dir != g_dir) snprintf(g_dir, sizeof g_dir, "%s", dir); g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; int maxdim = g_vw > g_vh ? g_vw : g_vh; @@ -1188,10 +1194,19 @@ static int open_book(const char *dir, int start_page, int mode) int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim, reader_load_page, reader_unload_page, NULL); if (n < 0) { + int err = errno; /* opendir/malloc 失败原因(设备排障用) */ g_open_err = 1; worker_start(); update_title(); - meow_log(MEOW_LOG_WARN, "reader open failed: %s", g_dir); + meow_log(MEOW_LOG_WARN, "reader open failed: %s (errno=%d %s)", g_dir, err, + strerror(err)); + /* 资源类失败(内存/FD 瞬时紧张)自动重试一次:library 页已在 on_leave 停止 + * 后台解码,稍后内存回收后重开即可成功;权限/路径错误不重试。 */ + if ((err == ENOMEM || err == EMFILE || err == ENFILE || + err == EAGAIN || err == EINTR) && !g_open_retry_used) { + g_open_retry_used = 1; + g_open_retry_at = meow_ui_time_ms() + 200; + } meow_ui_invalidate(0, 0, g_vw, g_vh); return 0; } @@ -1602,6 +1617,15 @@ static void reader_tick(void) int had_current = slot_find(current_page()) != NULL; if (results_drain() && !had_current && slot_find(current_page()) && !g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + + /* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */ + if (g_open_err && g_open_retry_at && now >= g_open_retry_at) { + g_open_retry_at = 0; + int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); + int page = mr_cont_get_page(g_dir, 0); + open_book(g_dir, page, mode); + return; + } if (g_fail_paint) { g_fail_paint = 0; if (!g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); @@ -1712,6 +1736,8 @@ static void reader_state_reset(void) slots_free_all(); /* 静态槽位初值是 idx=0/img=NULL,先清空 */ g_count = 0; g_open_err = 0; + g_open_retry_at = 0; + g_open_retry_used = 0; g_page = 0; g_mode = MR_MODE_PAGED; g_fit = MR_FIT_WIDTH; @@ -1869,6 +1895,7 @@ void mr_reader_open_dir(const char *path) int mr_reader_ui_mode(void) { return g_mode; } int mr_reader_ui_page(void) { return current_page(); } int mr_reader_ui_count(void) { return g_count; } +int mr_reader_ui_open_err(void) { return g_open_err; } int mr_reader_ui_jump_open(void) { return g_jump_open; } int mr_reader_ui_jump_pending(void) { return mr_jump_active(&g_jump); } #endif From 94eeabd78957116538db31b6e4c3c2e2822dc829 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 17:37:50 +0800 Subject: [PATCH 15/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E6=89=93=E5=8C=85?= =?UTF-8?q?=E6=94=B9=E6=A0=B9=E5=B8=83=E5=B1=80=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20meow=5Fsign=20=E4=B8=80=E9=94=AE=E7=AD=BE=E5=90=8D=EF=BC=88M?= =?UTF-8?q?16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tuku/README.md | 41 ++++++++++++++++++++ tuku/pack_tuku.sh | 96 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 tuku/README.md diff --git a/tuku/README.md b/tuku/README.md new file mode 100644 index 0000000..5361e54 --- /dev/null +++ b/tuku/README.md @@ -0,0 +1,41 @@ +# tuku(os.meow.picbrowser)打包与签名 + +图库 App(系统包名 `os.meow.picbrowser`)。构建与打包: + +```sh +./build_tuku.sh # 交叉编译 picbrowser(MIPS32r2 静态) +./pack_tuku.sh # 打包 → os.meow.picbrowser.mpk(根布局;未签名) +``` + +## 一键签名出包 + +```sh +MEOW_SIGN_KEY=/path/to/signing.key ./pack_tuku.sh +``` + +- 工具查找顺序:`$MEOW_SIGN` → `PATH` 里的 `meow_sign` → `$HOME/Proj/MeowOS-For-R3P2/patch_v2/meow_sign/meow_sign`。 +- 私钥:`$MEOW_SIGN_KEY`。未设置时脚本**警告并仍产出未签名包**(退出码 0);但 `os.meow.*` 系统包无签名会被设备拒装。 +- 有工具 + 密钥时执行 `meow_sign sign `;若存在 `.pub` 再执行 `meow_sign verify .pub`。 +- 私钥**不入库、不随包分发**;本仓不包含任何密钥材料。 + +## mpk 布局 + +`.mpk` = tar.gz,条目直接在根层: + +``` +os.meow.picbrowser.mpk +├── app.json +├── picbrowser +└── # 仅签名包有(Ed25519,尾部追加) +``` + +即 `tar -C build/os.meow.picbrowser -czf os.meow.picbrowser.mpk app.json picbrowser`—— +不带 `os.meow.picbrowser/` 目录前缀,也不带 `./` 前缀。脚本在签名前用 `tar -tzf` 断言这一点。 + +签名是往 tar.gz 尾部**追加** footer,签名后的 mpk 不要再跑 `tar -t` / `gzip` 之类的命令: +gzip 会报 `trailing garbage ignored`,属预期现象,不是包损坏。要检查布局请在签名前做。 + +## 安装 + +把 `.mpk` 放到设备 TF 卡,执行 `nyai install os.meow.picbrowser.mpk`; +`os.meow.*` 包必须验签通过(footer.package == 文件名茎 == `app.json.package`)。 diff --git a/tuku/pack_tuku.sh b/tuku/pack_tuku.sh index 0df32ef..d7348c9 100755 --- a/tuku/pack_tuku.sh +++ b/tuku/pack_tuku.sh @@ -1,14 +1,92 @@ -#!/bin/bash -# pack_tuku.sh — 出包 os.meow.picbrowser.mpk(tar.gz,根目录 os.meow.picbrowser/,含 app.json + picbrowser)。 -# .mpk 文件名茎 = app.json.package = 安装目录名(os.meow.picbrowser)。 +#!/usr/bin/env bash +# pack_tuku.sh — 一键出包 os.meow.picbrowser.mpk(根布局 tar.gz,可选内联签名)。 +# +# 布局:mpk 内的条目直接是 app.json + picbrowser(无 / 前缀、无 ./ 前缀), +# 与安装端「.mpk 内容在 tar 根」的解包口径一致。 +# +# 一键签名(推荐;os.meow.* 无签名设备会拒装): +# MEOW_SIGN_KEY=/path/to/signing.key ./pack_tuku.sh +# +# 环境变量: +# MEOW_SIGN 指定 meow_sign 可执行文件;未设时依次找 PATH 里的 meow_sign、 +# 再回退 $HOME/Proj/MeowOS-For-R3P2/patch_v2/meow_sign/meow_sign。 +# MEOW_SIGN_KEY 私钥路径。未设 → 警告并产出未签名包(退出码 0); +# 设了但找不到工具/密钥或签名失败 → 报错退出(不静默交付未签名包)。 +# 若同目录存在 .pub,签名后自动 verify。 +# +# 注意:签名是往 tar.gz 尾部追加 footer。签名后的 mpk **不要**再跑 tar/gzip 类命令 +# (gzip 会报 "trailing garbage ignored",属预期,不是包损坏)。因此根布局断言 +# 必须在签名前用 tar -tzf 做。 set -e cd "$(dirname "$0")" + +PKG=os.meow.picbrowser +MPK="$PKG.mpk" +STAGE="build/$PKG" + [ -x picbrowser ] || ./build_tuku.sh -rm -rf build/os.meow.picbrowser -mkdir -p build/os.meow.picbrowser -cp app.json picbrowser build/os.meow.picbrowser/ +rm -rf "$STAGE" +mkdir -p "$STAGE" +cp app.json picbrowser "$STAGE/" -rm -f os.meow.picbrowser.mpk -( cd build && tar czf ../os.meow.picbrowser.mpk os.meow.picbrowser ) -ls -la os.meow.picbrowser.mpk +rm -f "$MPK" +tar -C "$STAGE" -czf "$MPK" app.json picbrowser + +# 根布局断言(签名前):第一层有 app.json,且不带 / 或 ./ 前缀。 +listing="$(tar -C "$STAGE" -tzf "$MPK")" +printf '%s\n' "$listing" +if ! printf '%s\n' "$listing" | grep -qx 'app.json'; then + echo "错误:$MPK 根层缺少 app.json(布局不是根布局)" >&2 + exit 1 +fi +if printf '%s\n' "$listing" | grep -q "^\(\./\)\?$PKG/"; then + echo "错误:$MPK 仍带 $PKG/ 前缀(嵌套布局)" >&2 + exit 1 +fi + +find_sign_tool() { + if [ -n "${MEOW_SIGN:-}" ]; then + printf '%s\n' "$MEOW_SIGN" + elif command -v meow_sign >/dev/null 2>&1; then + command -v meow_sign + elif [ -x "$HOME/Proj/MeowOS-For-R3P2/patch_v2/meow_sign/meow_sign" ]; then + printf '%s\n' "$HOME/Proj/MeowOS-For-R3P2/patch_v2/meow_sign/meow_sign" + else + return 1 + fi +} + +if [ -z "${MEOW_SIGN_KEY:-}" ]; then + echo "警告:未签名(未设置 MEOW_SIGN_KEY),已产出未签名包 $MPK" >&2 + echo " 设 MEOW_SIGN_KEY=<私钥> 后重跑,或手动 sign;os.meow.* 未签名设备拒装。" >&2 +else + if [ ! -f "$MEOW_SIGN_KEY" ]; then + echo "错误:MEOW_SIGN_KEY=$MEOW_SIGN_KEY 不存在" >&2 + exit 1 + fi + if ! SIGN_TOOL="$(find_sign_tool)"; then + echo "错误:找不到 meow_sign(可用 MEOW_SIGN=/path/to/meow_sign 指定);$MPK 未签名" >&2 + exit 1 + fi + if [ ! -x "$SIGN_TOOL" ]; then + echo "错误:$SIGN_TOOL 不可执行(可用 MEOW_SIGN 指定可执行的 meow_sign);$MPK 未签名" >&2 + exit 1 + fi + if ! "$SIGN_TOOL" sign "$MPK" "$MEOW_SIGN_KEY"; then + echo "错误:meow_sign sign 失败,$MPK 仍是未签名包" >&2 + echo " 若提示 cannot execute / required file not found,多半是该二进制与本机不兼容,用 MEOW_SIGN 指定可执行版本。" >&2 + exit 1 + fi + if [ -f "$MEOW_SIGN_KEY.pub" ]; then + if ! "$SIGN_TOOL" verify "$MPK" "$MEOW_SIGN_KEY.pub"; then + echo "错误:meow_sign verify 失败,$MPK 签名无效" >&2 + exit 1 + fi + else + echo "提示:未找到 $MEOW_SIGN_KEY.pub,跳过 verify" + fi + # 签名后不要再 tar -t/-z:footer 在 gzip 流外,gzip 会报 trailing garbage(预期)。 +fi + +ls -la "$MPK" From 462cfd5bc0220320e19f32ea9f19c71d7ffbb77b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 18:06:26 +0800 Subject: [PATCH 16/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=99=A8=E7=BF=BB=E9=A1=B5=E6=8C=89=E6=98=BE=E7=A4=BA=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E8=A7=A3=E7=A0=81=EF=BC=88=E9=9B=B6=E9=87=8D=E9=87=87?= =?UTF-8?q?=E6=A0=B7=EF=BC=89+=20=E5=A4=9A=E9=A1=B5=E9=97=AA=E9=80=80?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=20+=20=E4=BB=8E=E7=AC=AC=20N=20=E5=BC=A0?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=EF=BC=88M17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈「横竖翻页都卡(40 页也卡)」「140 张图的文件夹一打开就闪退」 「要从中间某张开始看」三条,本次分别处理: 翻页卡顿(真因:每帧一次重采样) - 解码目标从「屏长边」改成「当前 fit/zoom 的显示尺寸」:适宽 3:4 页原来解成 540x720、显示 480x640,draw_page_at 于是每帧走 meow_ui_img_scaled —— 每页每帧 多一遍全图最近邻重采样 + 一次 1.2MB 临时 malloc/free(>128KB 走 mmap)。 - 现在解码结果与显示框一致(±2px 容差)→ 翻页/滚动热路径只有原生 blit。 - fit/zoom 变化时按新几何重解(slot_ready 判据 + 同页替换),不再残留缩放; 已到解码预算或已是原始分辨率的小图不会反复重解。 - 拖动/吸附/fling 帧只 invalidate 内容区(顶栏静态,不再每帧重绘标题文字); 异步落位只在「该页真的在屏幕上」时才重绘,预取邻居不刷屏。 - 页原始尺寸按册缓存(8B/页),绘制几何不再每帧 fopen probe 文件。 多页闪退(宿主未能复现,按内存边界防御) - 常驻页图改为「显示尺寸」并加上限:单页 ≤3MB、整窗 ≤9MB(超限淘汰最远页); 常驻内存只与解码窗大小有关,不随页数增长。 - 解码中间峰值 >12MB 的页直接跳过并显示占位 + WARN(旧代码 PNG/BMP 放到 40MB, 3000x3000 就是 36MB 瞬时分配;JPEG 完全没上限)。140 页夹具(含坏图/超大图) 走真实 UI 链路打开、翻页、跳页、条漫全部通过。 新增 mr_reader_open_dir_at(dir, start_index)(文件管理器模式入口) - 显式下标优先于续读记录;-1=续读(旧入口语义),其它负数钳到 0,≥页数钳到末页。 - mr_reader_open_dir 保留为 (dir, MR_READER_START_RESUME) 的包装;reader.h 导出。 测试:新增 test_reader_perf(翻页零重采样/fit 切换/驻留上界)与 test_reader_many_pages(140 页 + 坏图/超大图 + 起始下标/钳制/续读优先级 + 条漫跳页)。 --- tuku/test/run_tests.sh | 34 +++ tuku/test/test_reader_many_pages.c | 303 ++++++++++++++++++++++++ tuku/test/test_reader_perf.c | 222 ++++++++++++++++++ tuku/ui/reader/reader.c | 357 +++++++++++++++++++++++------ tuku/ui/reader/reader.h | 13 ++ 5 files changed, 858 insertions(+), 71 deletions(-) create mode 100644 tuku/test/test_reader_many_pages.c create mode 100644 tuku/test/test_reader_perf.c diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 92debcd..f55bec8 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -107,6 +107,40 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_open_book_host "$OUT"/test_open_book_host + + # 翻页热路径(M17):解码=显示尺寸 → 翻页零重采样;fit 切换按新几何重解。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_reader_perf.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_reader_perf + "$OUT"/test_reader_perf + + # 140 页夹打开/翻页/跳页 + mr_reader_open_dir_at(文件管理器模式入口)。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_reader_many_pages.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_reader_many_pages + "$OUT"/test_reader_many_pages else echo "SKIP test_settings_lock_host/test_browse_ui_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi diff --git a/tuku/test/test_reader_many_pages.c b/tuku/test/test_reader_many_pages.c new file mode 100644 index 0000000..d81a1ba --- /dev/null +++ b/tuku/test/test_reader_many_pages.c @@ -0,0 +1,303 @@ +/* test_reader_many_pages.c — 多页(140 张)文件夹打开/翻页/跳页回归 +「从第 N 张开始」API。 + * + * 用户反馈:「文件夹里图片过多时(例如 140 个),APP 会直接闪退」;且后续书库要改成文件 + * 管理器模式,需要「从选中的那张开始看」。本测试走真实 SDK UI 链路(view/activity/anim/ + * 真实解码),断言: + * M1 140 页册可打开,页数/模式正确(翻页模式、第 1 页); + * M2 mr_reader_open_dir_at(dir, 57):从第 57 页(0 基)开始;越界 9999 → 末页;负数 → 首页; + * M3 显式下标优先于续读记录;mr_reader_open_dir 仍走续读记录(旧语义不变); + * M4 连续翻页穿过坏图(解码失败)不崩、不卡死,坏页给占位后继续翻; + * M5 条漫模式:140 页排版/跳页/滚动可用; + * M6 常驻页图有字节上界(<=9MB)——页数增长不会线性吃内存。 + * + * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "meow_ui_internal.h" + +#include +#include +#include +#include +#include +#include + +#define BOOK_PAGES 140 + +void mr_nav_report(void) {} + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +static int write_bmp24(const char *path, int w, int h, unsigned seed) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < w; x++) { + row[x * 3 + 0] = (unsigned char)((x + seed) & 0xFF); + row[x * 3 + 1] = (unsigned char)((y + seed) & 0xFF); + row[x * 3 + 2] = (unsigned char)(seed & 0xFF); + } + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int write_garbage(const char *path) +{ + static const unsigned char junk[64] = { 'N', 'O', 'T', 'A', 'N', 'I', 'M', 'G' }; + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(junk, 1, sizeof junk, f) == sizeof junk; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +/* 合法 BMP24 头(声称 3000x3000,无像素数据):stbi_info 能读到尺寸, + * 整幅 RGBA 峰值 36MB 必须被 MR_DECODE_PEAK_CAP(12MB) 在解码前拦下 + * (旧代码的 raw 上限是 40MB,这种页会被放行 → 真机上瞬时分配即可能被 OOM 杀掉)。 */ +static int write_huge_bmp_header(const char *path) +{ + unsigned char b[54] = { 0 }; + b[0] = 'B'; b[1] = 'M'; + put32(b + 2, 54); /* 文件大小(谎报,只为通过头检查) */ + put32(b + 10, 54); /* 像素数据偏移 */ + put32(b + 14, 40); /* BITMAPINFOHEADER */ + put32(b + 18, 3000); /* width */ + put32(b + 22, 3000); /* height */ + put16(b + 26, 1); /* planes */ + put16(b + 28, 24); /* bpp */ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(b, 1, sizeof b, f) == sizeof b; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +static int tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(300); + return 1; +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_many_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX], book[MR_PATH_MAX], bad[MR_PATH_MAX]; + snprintf(root, sizeof root, "./mr_many_books_%ld", (long)getpid()); + snprintf(book, sizeof book, "%s/v140", root); + snprintf(bad, sizeof bad, "%s/vbad", root); + if (mkdir_p(book) != 0 || mkdir_p(bad) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + for (int i = 0; i < BOOK_PAGES; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%03d.bmp", book, i + 1); + if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + /* 坏册:第 2 页是垃圾字节(解码失败 → 占位,不许崩/死循环)。 */ + for (int i = 0; i < 4; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%d.bmp", bad, i + 1); + if (i == 1) { if (write_garbage(p) != 0) { printf("SKIP: write failed\n"); return 0; } } + else if (i == 2) { if (write_huge_bmp_header(p) != 0) { printf("SKIP: write failed\n"); return 0; } } + else if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { 0 }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + int w = meow_ui_width(); + int mode_x = w - 10 - 48 * 3 + 24; + int jump_x = w - 10 - 48 * 2 + 24; + int bar_y = 28; + int slider_y = 56 + 4 + 20; + int sx = 24, sw = w - 48; + + /* ---- M1 140 页可打开 ---- */ + mr_reader_open_dir_at(book, 0); + CHECK(meow_ui_activity_active() == ACT_READER, "reader active"); + CHECK(mr_reader_ui_count() == BOOK_PAGES, "pages=%d (want %d)", + mr_reader_ui_count(), BOOK_PAGES); + CHECK(mr_reader_ui_page() == 0, "start at page 0 (got %d)", mr_reader_ui_page()); + pump_ms(600); + + /* ---- M2 从第 N 张开始 / 越界钳制 ---- */ + meow_view_page_pop(); + mr_reader_open_dir_at(book, 57); + CHECK(mr_reader_ui_page() == 57, "open_dir_at(57) -> page %d", mr_reader_ui_page()); + pump_ms(400); + meow_view_page_pop(); + + mr_reader_open_dir_at(book, 9999); + CHECK(mr_reader_ui_page() == BOOK_PAGES - 1, "open_dir_at(9999) clamps to last (%d)", + mr_reader_ui_page()); + pump_ms(400); + meow_view_page_pop(); + + mr_reader_open_dir_at(book, -5); + CHECK(mr_reader_ui_page() == 0, "open_dir_at(-5) clamps to first (%d)", mr_reader_ui_page()); + pump_ms(300); + meow_view_page_pop(); + + /* ---- M3 显式下标优先于续读;open_dir 保持续读语义 ---- */ + mr_reader_open_dir_at(book, 77); /* 离开时会把 77 落盘 */ + pump_ms(300); + meow_view_page_pop(); + mr_reader_open_dir_at(book, 12); /* 显式下标赢过续读记录 77 */ + CHECK(mr_reader_ui_page() == 12, "explicit start beats resume record (%d)", + mr_reader_ui_page()); + pump_ms(300); + meow_view_page_pop(); + mr_reader_open_dir(book); /* 旧入口 = 续读 */ + CHECK(mr_reader_ui_page() == 12, "open_dir resumes saved page (%d)", mr_reader_ui_page()); + pump_ms(300); + + /* ---- M4 连续翻页(含异步解码)不崩、页码推进、内存有界 ---- */ + int ok_move = 1; + for (int i = 0; i < 12; i++) { + int before = mr_reader_ui_page(); + tap(40, 300); + pump_ms(260); + if (mr_reader_ui_page() == before) ok_move = 0; + } + CHECK(ok_move, "12 consecutive flips advanced the page"); + CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded on 140p book (%ldKB)", + mr_reader_ui_resident_kb()); + + /* 坏图册:翻过坏页不崩、可继续翻。 */ + meow_view_page_pop(); + mr_reader_open_dir_at(bad, 0); + pump_ms(600); + int bad_ok = 1; + for (int i = 0; i < 5; i++) { + int before = mr_reader_ui_page(); + tap(40, 300); + pump_ms(300); + if (mr_reader_ui_page() == before && before < 3) bad_ok = 0; + } + CHECK(bad_ok, "flip across corrupt/huge pages keeps working (page=%d)", mr_reader_ui_page()); + CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, + "huge page skipped without allocating it (%ldKB)", mr_reader_ui_resident_kb()); + CHECK(mr_reader_ui_failed_pages() == 2, + "garbage + oversized page both degraded to placeholder (failed=%d)", + mr_reader_ui_failed_pages()); + CHECK(meow_ui_activity_active() == ACT_READER, "still in reader after corrupt page"); + + /* ---- M5 条漫:140 页排版 + 跳页落位 ---- */ + meow_view_page_pop(); + mr_reader_open_dir_at(book, 0); + pump_ms(400); + CHECK(mr_reader_ui_count() == BOOK_PAGES, "back on the 140p book (%d)", mr_reader_ui_count()); + CHECK(tap(mode_x, bar_y) == 1, "mode icon consumed"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon mode on 140 pages"); + pump_ms(500); + CHECK(tap(jump_x, bar_y) == 1, "jump icon consumed"); + CHECK(mr_reader_ui_jump_open() == 1, "slider open"); + reader_on_touch_down(sx + sw, slider_y); + reader_on_tap(sx + sw, slider_y); + pump_ms(900); + CHECK(mr_reader_ui_jump_pending() == 0, "jump landed in webtoon"); + CHECK(mr_reader_ui_page() >= BOOK_PAGES - 3, "jumped near last page (got %d)", + mr_reader_ui_page()); + CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded in webtoon (%ldKB)", + mr_reader_ui_resident_kb()); + + meow_view_page_pop(); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(root); + rm_rf(data); + + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "many-pages reader checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c new file mode 100644 index 0000000..14843f2 --- /dev/null +++ b/tuku/test/test_reader_perf.c @@ -0,0 +1,222 @@ +/* test_reader_perf.c — 阅读页翻页热路径回归(需要 SDK 源码树,宿主跑真实 view/anim/img)。 + * + * 用户反馈:「翻页很卡,横翻竖翻都卡」「40 页也卡」。真因是每帧一次 meow_ui_img_scaled: + * 解码尺寸(maxdim=屏长边) ≠ 显示尺寸(适宽/适高) → 每帧重采样 + 每帧 1.2MB 临时缓冲 + * malloc/free。修法:解码期直接按显示尺寸落地,翻页只做原生 blit。 + * + * 本测试用 600x800(3:4)合成 BMP 当页图,断言: + * P1 适宽:解码 480x640 == 显示框 → 绘页零重采样(scaled_draws 不增长); + * P2 连续翻页 10 次(预取窗口命中):每帧仍零重采样,且确实画了页(blit 增长); + * P3 切「适高」:重解到 540x720 后同样零重采样(fit 变化会按新几何重解,不残留缩放); + * P4 驻留内存有上界(<= 9MB:3~4 张显示尺寸页)。 + * + * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "meow_ui_internal.h" + +#include +#include +#include +#include +#include +#include + +void mr_nav_report(void) {} + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +/* 合成 BMP24(stb 可解);填一点非零像素,避免全 0 触发任何「透明」特判。 */ +static int write_bmp24(const char *path, int w, int h, unsigned seed) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < w; x++) { + row[x * 3 + 0] = (unsigned char)((x + seed) & 0xFF); + row[x * 3 + 1] = (unsigned char)((y * 3 + seed) & 0xFF); + row[x * 3 + 2] = (unsigned char)((x ^ y) & 0xFF); + } + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +static void tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(300); /* 越过双击判定窗口,让单击生效 */ +} + +static void draw_once(void) { meow_ui_paint_full(); } + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_perf_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char book[MR_PATH_MAX]; + snprintf(book, sizeof book, "./mr_perf_books_%ld/v1", (long)getpid()); + if (mkdir_p(book) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + for (int i = 0; i < 4; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%d.bmp", book, i + 1); + if (write_bmp24(p, 600, 800, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { 0 }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + int w = meow_ui_width(); + int fit_x = w - 10 - 48 * 3 + 24 + 48 * 2; /* 顶栏第 3 个动作 = fit */ + int bar_y = 28; + + /* ---- P1 进入:适宽 480x640 == 解码尺寸 → 纯 blit ---- */ + mr_reader_open_dir(book); + CHECK(mr_reader_ui_count() == 4, "book pages=%d", mr_reader_ui_count()); + pump_ms(700); + draw_once(); + int b0 = mr_reader_ui_blit_draws(), s0 = mr_reader_ui_scaled_draws(); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > b0, "page drawn with native blit"); + CHECK(mr_reader_ui_scaled_draws() == s0, + "fit-width: zero resample per frame (scaled %d -> %d)", + s0, mr_reader_ui_scaled_draws()); + + /* ---- P2 连续翻页:预取命中,仍零重采样 ---- */ + int bt = mr_reader_ui_blit_draws(), st = mr_reader_ui_scaled_draws(); + int pages_ok = 1; + for (int i = 0; i < 10; i++) { + int before = mr_reader_ui_page(); + /* RTL 默认:左 1/3=下一页、右 1/3=上一页;来回翻避免撞到首末页的册间衔接。 */ + tap(i % 2 ? w - 40 : 40, 300); + pump_ms(350); + if (mr_reader_ui_page() == before) pages_ok = 0; + draw_once(); + } + CHECK(pages_ok, "10 flips advanced the page each time"); + CHECK(mr_reader_ui_blit_draws() - bt >= 10, "flips drew pages (%d blits)", + mr_reader_ui_blit_draws() - bt); + CHECK(mr_reader_ui_scaled_draws() == st, + "flip hot path: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); + + /* ---- P3 切「适高」:按新几何重解后再测 ---- */ + tap(fit_x, bar_y); /* 适宽 → 适高 */ + pump_ms(600); + draw_once(); + int s2 = mr_reader_ui_scaled_draws(); + for (int i = 0; i < 5; i++) { + tap(i % 2 ? w - 40 : 40, 300); + pump_ms(350); + draw_once(); + } + CHECK(mr_reader_ui_scaled_draws() == s2, + "fit-height: re-decoded page still zero resample (scaled %d -> %d)", + s2, mr_reader_ui_scaled_draws()); + + /* ---- P4 驻留内存上界 ---- */ + long kb = mr_reader_ui_resident_kb(); + printf(" info: resident=%ldKB blit=%d scaled=%d\n", kb, + mr_reader_ui_blit_draws(), mr_reader_ui_scaled_draws()); + CHECK(kb <= 9 * 1024, "resident page images bounded (got %ldKB)", kb); + + meow_view_page_pop(); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(book); + char rootdir[MR_PATH_MAX]; + snprintf(rootdir, sizeof rootdir, "./mr_perf_books_%ld", (long)getpid()); + rm_rf(rootdir); + rm_rf(data); + + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "reader flip hot-path checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index fd717d2..04d9d4b 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -10,6 +10,12 @@ * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。条漫排版用 * meta() 不解码像素;解码入口走 SDK M12 预算解码 meow_img_load_file_dim(JPEG 解码期缩解, * PNG/BMP 整幅解码后降采样——超大头先 meow_img_probe_file 预判,超过预算跳过并告警)。 + * + * 性能(M17):解码目标 = **当前 fit/zoom 的显示尺寸**(不是屏长边),所以翻页/滚动热路径 + * 只有原生 blit:不做 meow_ui_img_scaled 的每帧重采样,也不再每帧 malloc 1.2MB 临时缓冲。 + * fit 变化时窗口按新几何重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 + * 内存:单页显示缓存 ≤3MB、整窗 ≤9MB(超限淘汰最远页),解码中间峰值 >12MB 的页直接跳过 + * 并显示占位——页数增长不会线性吃内存(常驻只与解码窗大小有关)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page @@ -64,8 +70,11 @@ #define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ #define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ #define MR_TOAST_MS 1400 -#define MR_LOAD_RAW_WARN (12L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ -#define MR_LOAD_RAW_CAP (40L * 1024 * 1024) /* 超此值直接跳过(防 OOM;页显示占位) */ +#define MR_LOAD_RAW_WARN (8L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ +#define MR_DECODE_PEAK_CAP (12L * 1024 * 1024) /* 单次解码中间缓冲峰值上限(超则跳过,防 OOM 闪退) */ +#define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */ +#define MR_RESIDENT_CAP (9L * 1024 * 1024) /* 页图缓存总字节上限(超则淘汰最远页) */ +#define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ #define MR_REQ_MAX 8 #define MR_RES_MAX 4 @@ -75,6 +84,7 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; +static int g_start_at = MR_READER_START_RESUME; /* 下次 on_enter 的显式起始页(-1=续读) */ static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; @@ -133,6 +143,14 @@ static page_slot g_slot[MR_SLOTS]; /* 解码失败页标记:避免对坏图/超大图无限重试,UI 显示「图片无法显示」而不是一直加载中。 */ static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8]; +/* 每页原始像素尺寸缓存(打开时按 count 分配;0,0=未知)。绘制/排版几何只查这里, + * 不在每帧去 probe 文件(dir_ops_meta 是 fopen+读头,每帧一次会直接拖垮翻页)。 */ +typedef struct page_meta { int w, h; } page_meta; +static page_meta *g_meta; + +/* 解码预算(屏长边):超过它的显示尺寸无法靠「再解一次」变大,用于避免重解死循环。 */ +static int g_decode_budget = 720; + static int page_failed(int idx) { if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0; @@ -183,6 +201,17 @@ static int g_wo_last_y; /* 条漫:上一次 move 的 y */ static int g_wo_y0; /* 条漫:按下点 y(slop 判定) */ static int g_wo_pull; /* 条漫:到边后继续拉的累计量(>0 往末页方向) */ +/* ---- 绘页路径计数(宿主测试观察:翻页热路径应全部是 blit)---- */ +#ifdef MR_READER_HOST_TEST +static int g_blit_draws; +static int g_scaled_draws; +#define NOTE_BLIT() (g_blit_draws++) +#define NOTE_SCALED() (g_scaled_draws++) +#else +#define NOTE_BLIT() ((void)0) +#define NOTE_SCALED() ((void)0) +#endif + /* ---- 单击延迟(双击判定窗口)---- */ static int g_tap_pending; static int g_tap_x, g_tap_y; @@ -197,6 +226,8 @@ static void mode_set(int mode, int from_page); static void toast(const char *msg); static void slider_place(void); static void update_title(void); +static void invalidate_content(void); +static page_slot *slot_find(int idx); static void cont_touch(void); static void cont_save_now(void); static int current_page(void); @@ -213,34 +244,66 @@ static int path_is_jpeg(const char *path) return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0; } +/* 目标解码长边:优先=当前 fit/zoom 的显示尺寸(解码后可直接 blit,翻页零重采样), + * 再受屏长边预算与单页字节上限约束。pw/ph=原始尺寸。 */ +static int decode_long_side(int pw, int ph, int budget) +{ + int tw = 0, th = 0; + int long0 = pw > ph ? pw : ph; + int short0 = pw > ph ? ph : pw; + if (mr_nav_fit_size(g_fit, pw, ph, g_vw, g_vh, &tw, &th) != 0) + return budget; + if (g_zoom_pct != 100) mr_nav_zoom_size(tw, th, g_zoom_pct, &tw, &th); + int l = tw > th ? tw : th; + if (l < 64) l = 64; + if (budget > 0 && l > budget) l = budget; + /* 单页缓存上限:长边按比例收到能装下为止(高度极大的长条漫走这里)。 */ + while (l > 64) { + int s = (int)((long long)short0 * l / long0); + if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break; + l = l * 3 / 4; + } + return l; +} + static meow_img *reader_load_page(const char *path, void *ud, int maxdim) { (void)ud; if (!path || !path[0]) return NULL; /* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。 - * - 非缩解格式(PNG/BMP)原始 RGBA 超过预算 → 跳过,避免真机 OOM(页面显示占位); - * - 超过告警线 → 记 WARN(残余风险,按 M12 报告口径如实提示)。 */ + * - 解码中间缓冲峰值超上限 → 跳过(页面显示占位),避免真机被 OOM 杀掉(闪退); + * - 非缩解格式(PNG/BMP)原始 RGBA 超过告警线 → 记 WARN(残余风险)。 */ int pw = 0, ph = 0, ch = 0; - if (meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0) { - long long raw = (long long)pw * (long long)ph * 4; - if (!path_is_jpeg(path)) { - if (raw > MR_LOAD_RAW_CAP) { - meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (raw %lldKB > cap): %s", - pw, ph, raw / 1024, path); - return NULL; - } - if (raw > MR_LOAD_RAW_WARN) + int have = meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0; + int dim = maxdim; + if (have) { + int l0 = pw > ph ? pw : ph; + dim = decode_long_side(pw, ph, maxdim); + long long peak; + if (path_is_jpeg(path)) { + /* 估 stbi 缩解后的输出(与 SDK 的档位选择同式):仍超上限才跳过。 */ + int s = 1; + while (s < 8 && (l0 / (s * 2)) >= dim) s *= 2; + peak = (long long)(pw / s) * (ph / s) * 4; + } else { + peak = (long long)pw * ph * 4; /* 整幅解码后降采样 */ + if (peak > MR_LOAD_RAW_WARN) meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s", - pw, ph, raw / 1024, path); + pw, ph, peak / 1024, path); + } + if (peak > MR_DECODE_PEAK_CAP) { + meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (peak %lldKB > cap): %s", + pw, ph, peak / 1024, path); + return NULL; } } - meow_img *im = meow_img_load_file_dim(path, maxdim); + meow_img *im = meow_img_load_file_dim(path, dim); if (!im) return NULL; /* 兜底:M12 之前的格式/未知格式若没被缩到预算内,再降一次(保持驻留纪律)。 */ - if (maxdim > 0 && (meow_img_w(im) > maxdim || meow_img_h(im) > maxdim)) { - meow_img *small = meow_img_downscale(im, maxdim); + if (dim > 0 && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { + meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } } return im; @@ -291,6 +354,54 @@ static void log_mem(const char *tag) mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); } +/* ============================================================ */ +/* 页原始尺寸缓存(绘制几何的唯一来源;每页只 probe 一次) */ +/* ============================================================ */ +static int page_meta_dims(int idx, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (idx < 0 || idx >= g_count) return 0; + if (g_meta) { + if (g_meta[idx].w > 0 && g_meta[idx].h > 0) { + if (w) *w = g_meta[idx].w; + if (h) *h = g_meta[idx].h; + return 1; + } + if (g_meta[idx].w < 0) return 0; /* 探测过且未知:别再每帧 fopen 一遍 */ + } + int sw = 0, sh = 0; + if (!mr_book_meta(&g_book, idx, &sw, &sh) || sw <= 0 || sh <= 0) { + if (g_meta) { g_meta[idx].w = -1; g_meta[idx].h = 0; } + return 0; + } + if (g_meta) { g_meta[idx].w = sw; g_meta[idx].h = sh; } + if (w) *w = sw; + if (h) *h = sh; + return 1; +} + +/* 显示框(fit + zoom):绘图与点击几何都走这里。未知名按 2:3 竖图占位。 */ +static void page_target_size_at(int idx, int *w, int *h) +{ + int sw = 0, sh = 0; + if (w) *w = 0; + if (h) *h = 0; + if (!w || !h) return; + if (!page_meta_dims(idx, &sw, &sh)) { + /* meta 不可用(未知/坏头):用已解码尺寸兜底(保持真实长宽比), + * 都没解出来才按 2:3 竖图占位——排版稳定且不会把页面拉变形。 */ + page_slot *s = slot_find(idx); + if (s) { sw = s->src_w; sh = s->src_h; } + if (sw <= 0 || sh <= 0) { + sw = g_vw > 0 ? g_vw : 1; + sh = sw * 3 / 2; + } + } + if (mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h) != 0) return; + if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); +} + /* ============================================================ */ /* 续读状态(逐册 last page + mode;写入节流,避免滚动时频繁写卡) */ /* ============================================================ */ @@ -351,6 +462,18 @@ static int in_window(int idx) return 0; } +/* 该页此刻是否真的画在屏幕上(异步落位后只重绘这种情况)。 */ +static int page_visible(int idx) +{ + if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + int f = mr_strip_page_at(&g_strip, g_woff); + int l = mr_strip_page_at(&g_strip, g_woff + g_vh - 1); + return idx >= f && idx <= l; + } + return idx == g_page || (g_slide_delta != 0 && idx == g_page + g_slide_delta); +} + static page_slot *slot_find(int idx) { if (idx < 0) return NULL; @@ -373,20 +496,70 @@ static void slots_free_all(void) for (int i = 0; i < MR_SLOTS; i++) slot_clear(i); } -/* 放进缓存;缓存满时淘汰离焦点页最远的一张。返回 1=已放入。 */ +/* 缓存图是否就是当前 fit/zoom 的显示尺寸(可直接 blit)。已达解码预算的图也算就绪, + * 否则「原始/放大」档会陷入「解不出目标尺寸 → 反复重解」。 */ +static int slot_ready(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return 0; + int tw = 0, th = 0; + page_target_size_at(idx, &tw, &th); + if (tw <= 0 || th <= 0) return 1; + if (abs(tw - s->src_w) <= MR_SLOT_TOL && abs(th - s->src_h) <= MR_SLOT_TOL) return 1; + if (g_decode_budget > 0 && + (s->src_w >= g_decode_budget || s->src_h >= g_decode_budget)) return 1; + /* 已是原始分辨率(小图不放大,SDK 不做上采样):这就是能拿到的最好结果。 */ + int mw = 0, mh = 0; + if (page_meta_dims(idx, &mw, &mh) && s->src_w >= mw && s->src_h >= mh) return 1; + return 0; +} + +/* 淘汰离焦点页最远的一张(不淘汰 focus 与 except)。返回被清掉的槽下标,无则 -1。 */ +static int slot_evict_far(int except) +{ + int focus = focus_page(); + int far = -1, far_d = -1; + for (int i = 0; i < MR_SLOTS; i++) { + if (i == except || !g_slot[i].img) continue; + if (g_slot[i].idx == focus) continue; + int d = abs(g_slot[i].idx - focus); + if (d > far_d) { far_d = d; far = i; } + } + if (far >= 0) slot_clear(far); + return far; +} + +/* 放进缓存;同页旧图直接替换(fit/zoom 变了要换尺寸)。返回 1=已放入。 */ static int slot_put(int idx, meow_img *img) { if (!img || idx < 0) return 0; - int focus = focus_page(); - int empty = -1, far = -1, far_d = -1; - for (int i = 0; i < MR_SLOTS; i++) { - if (g_slot[i].idx == idx && g_slot[i].img) { meow_img_free(img); return 0; } - if (!g_slot[i].img && empty < 0) empty = i; - int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - focus); - if (d > far_d) { far_d = d; far = i; } + int i = -1, empty = -1; + for (int k = 0; k < MR_SLOTS; k++) { + if (g_slot[k].idx == idx && g_slot[k].img) { i = k; break; } + if (!g_slot[k].img && empty < 0) empty = k; + } + if (i < 0) { + if (empty >= 0) { + i = empty; + } else { /* 全满:替换离焦点页最远的一张 */ + int focus = focus_page(), far_d = -1; + for (int k = 0; k < MR_SLOTS; k++) { + int d = abs(g_slot[k].idx - focus); + if (d > far_d) { far_d = d; i = k; } + } + } } - int i = empty >= 0 ? empty : far; if (i < 0) { meow_img_free(img); return 0; } + + /* 总字节上限:新图放进去前先淘汰最远页,保证常驻图不超过预算。 */ + long need = (long)meow_img_w(img) * meow_img_h(img) * 4; + for (;;) { + long held = resident_bytes(); + if (g_slot[i].img) held -= (long)g_slot[i].src_w * g_slot[i].src_h * 4; + if (held + need <= MR_RESIDENT_CAP) break; + if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */ + } + slot_clear(i); g_slot[i].idx = idx; g_slot[i].img = img; @@ -456,7 +629,7 @@ static int req_queued(int idx) static void queue_page(int idx) { if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - if (slot_find(idx) || page_failed(idx)) return; + if (slot_ready(idx) || page_failed(idx)) return; pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { g_req[g_req_tail].idx = idx; @@ -562,11 +735,11 @@ static int results_drain(void) if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } if (got[i].fail) { page_mark_failed(got[i].idx); - if (in_window(got[i].idx)) g_fail_paint = 1; + if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } - if (in_window(got[i].idx) && !slot_find(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img)) changed = 1; + if (in_window(got[i].idx)) { + if (slot_put(got[i].idx, got[i].img) && page_visible(got[i].idx)) changed = 1; } else { meow_img_free(got[i].img); } @@ -590,7 +763,8 @@ static void window_update(void) /* ============================================================ */ static int strip_meta_cb(void *ctx, int index, int *w, int *h) { - return mr_book_meta((const mr_book *)ctx, index, w, h); + (void)ctx; + return page_meta_dims(index, w, h); /* 走缓存:重排版不再逐页 fopen probe */ } static int strip_ensure(void) @@ -641,7 +815,7 @@ static void wo_set(void *ctx, int off) static void wo_redraw(void *ctx) { (void)ctx; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); /* 滚动只动内容区,顶栏静态不重绘 */ } static void scroll_ensure(void) @@ -661,27 +835,23 @@ static void scroll_free(void) /* ============================================================ */ /* 布局/绘制 */ /* ============================================================ */ -static void page_target_size(const page_slot *s, int *w, int *h) +/* 内容视口失效区:chrome 可见时顶栏是静态的,拖动/吸附帧只刷其下内容, + * 避免每帧重绘顶栏文字(软渲下文字是最贵的绘制物)。 */ +static void invalidate_content(void) { - *w = *h = 0; - if (!s) return; - if (mr_nav_fit_size(g_fit, s->src_w, s->src_h, g_vw, g_vh, w, h) != 0) return; - if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); + if (g_chrome) + meow_ui_invalidate(0, MR_TOP_BAR_H, g_vw, g_vh - MR_TOP_BAR_H); + else + meow_ui_invalidate(0, 0, g_vw, g_vh); } static void current_geom(int *w, int *h) { - page_slot *s = slot_find(g_page); - if (s) { page_target_size(s, w, h); return; } - /* 页未就绪:用 meta 估算,保证平移/缩放的夹取稳定。 */ - int sw = 0, sh = 0; - if (mr_book_meta(&g_book, g_page, &sw, &sh) && sw > 0 && sh > 0) { - mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h); - if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); - return; + page_target_size_at(g_page, w, h); + if (!w || !h || *w <= 0 || *h <= 0) { + if (w) *w = g_vw; + if (h) *h = g_vh; } - *w = g_vw; - *h = g_vh; } static void clamp_pan(void) @@ -705,18 +875,24 @@ static int pan_mode(void) return g_zoom_pct > 100 || content_overflow(); } -/* 画一页(带水平位移);图与目标尺寸相等时走原生 blit(翻页热路径),否则最近邻缩放。 */ +/* 画一页(带水平位移);解码尺寸==显示尺寸时走原生 blit(翻页热路径零重采样), + * 只在 fit/zoom 变化或极端长页(超单页缓存上限)时才退回最近邻缩放。 */ static void draw_page_at(int idx, int off_x, int off_y) { page_slot *s = slot_find(idx); if (!s) return; int w, h; - page_target_size(s, &w, &h); + page_target_size_at(idx, &w, &h); if (w <= 0 || h <= 0) return; int x = (g_vw - w) / 2 + g_pan_x + off_x; int y = (g_vh - h) / 2 + g_pan_y + off_y; - if (w == s->src_w && h == s->src_h) meow_ui_img(x, y, s->img); - else meow_ui_img_scaled(x, y, w, h, s->img); + if (abs(w - s->src_w) <= MR_SLOT_TOL && abs(h - s->src_h) <= MR_SLOT_TOL) { + meow_ui_img(x + (w - s->src_w) / 2, y + (h - s->src_h) / 2, s->img); + NOTE_BLIT(); + } else { + meow_ui_img_scaled(x, y, w, h, s->img); + NOTE_SCALED(); + } } /* 条漫:按滚动偏移画可见页。典型 2:3 页的解码尺寸与显示尺寸相等 → 原生 blit; @@ -735,8 +911,15 @@ static void draw_strip(const meow_theme *th) if (h <= 0) continue; page_slot *s = slot_find(i); if (s) { - if (s->src_w == g_vw && s->src_h == h) meow_ui_img(0, y, s->img); - else meow_ui_img_scaled(0, y, g_vw, h, s->img); + /* 解码=显示尺寸时原生 blit:gfx 只处理屏幕上可见的行, + * 长条页只露一截时不再整页重采样(M7 finding 的子矩形 blit 诉求)。 */ + if (abs(s->src_w - g_vw) <= MR_SLOT_TOL && abs(s->src_h - h) <= MR_SLOT_TOL) { + meow_ui_img((g_vw - s->src_w) / 2, y, s->img); + NOTE_BLIT(); + } else { + meow_ui_img_scaled(0, y, g_vw, h, s->img); + NOTE_SCALED(); + } continue; } /* 未就绪:只填可见部分(长页不整块清屏),给一帧占位而不是白屏。 */ @@ -907,7 +1090,7 @@ static void pan_by(int dx, int dy) g_pan_x += dx; g_pan_y += dy; clamp_pan(); - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } static void commit_page(int target) @@ -929,7 +1112,7 @@ static void slide_update(meow_anim *a, float value, void *ctx) { (void)a; (void)ctx; g_anim_v = (int)value; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); /* 吸附帧只刷内容区(整屏 minus 静态顶栏) */ } static void slide_done(meow_anim *a, int canceled, void *ctx) @@ -944,7 +1127,7 @@ static void slide_done(meow_anim *a, int canceled, void *ctx) if (!canceled && delta) commit_page(g_page + delta); else - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); meow_log(MEOW_LOG_INFO, "reader flip done page=%d/%d total_ms=%llu", g_page + 1, g_count, (unsigned long long)(meow_ui_time_ms() - g_flip_t0)); @@ -982,7 +1165,7 @@ static void bounce_update(meow_anim *a, float value, void *ctx) { (void)a; (void)ctx; g_drag_off = (int)value; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } static void bounce_done(meow_anim *a, int canceled, void *ctx) @@ -991,7 +1174,7 @@ static void bounce_done(meow_anim *a, int canceled, void *ctx) g_bounce = NULL; g_drag_off = 0; g_drag_mode = 0; - if (!g_quitting) meow_ui_invalidate(0, 0, g_vw, g_vh); + if (!g_quitting) invalidate_content(); } static void start_bounce(int from_off) @@ -1002,7 +1185,7 @@ static void start_bounce(int from_off) bounce_update, bounce_done, NULL, }; g_bounce = meow_anim_start(&d); - if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; meow_ui_invalidate(0, 0, g_vw, g_vh); } + if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; invalidate_content(); } } /* 点击触发的翻页(无跟手位移)。首/末页再前进 → 相邻册(无相邻册则轻提示)。 */ @@ -1176,6 +1359,8 @@ static void open_book_reset_state(void) mr_jump_cancel(&g_jump); g_toast[0] = '\0'; g_title_page = -1; + free(g_meta); + g_meta = NULL; } /* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ @@ -1189,6 +1374,7 @@ static int open_book(const char *dir, int start_page, int mode) g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; int maxdim = g_vw > g_vh ? g_vw : g_vh; + g_decode_budget = maxdim; /* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约, * 下面的翻页/条漫消费代码完全不区分 provider。 */ int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim, @@ -1211,6 +1397,10 @@ static int open_book(const char *dir, int start_page, int mode) return 0; } g_count = n; + /* 页尺寸缓存:每页 8 字节(4096 页上限 ≈32KB),换来的是一帧内零 fopen probe。 */ + g_meta = (page_meta *)calloc((size_t)g_count, sizeof *g_meta); + if (!g_meta) + meow_log(MEOW_LOG_WARN, "reader meta cache unavailable (%d pages)", g_count); int page = start_page < 0 ? g_count - 1 : start_page; page = mr_nav_clamp_page(page, g_count); @@ -1289,7 +1479,7 @@ static void drag_paint(void) int gap = g_zoom_pct > 100 ? 33 : MR_PAINT_MS; /* 缩放帧要重采样:30fps 上限 */ if (now - g_last_paint < (uint64_t)gap) return; g_last_paint = now; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } /* 松手:过阈值且目标页已缓存 → 从当前位移吸附过去;否则回弹。 */ @@ -1303,7 +1493,7 @@ static void drag_finish(void) g_drag_mode = 0; if (mode != 1 || abs(dx) < MR_DRAG_SLOP) { - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); return; } int rtl = mr_reader_get_direction() == MR_DIR_RTL; @@ -1312,7 +1502,7 @@ static void drag_finish(void) if (mr_nav_commit(dx, g_vw)) { if (target == g_page) { /* 首/末页越界:进相邻册 */ book_switch(delta > 0 ? 1 : -1); - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); return; } g_flip_t0 = meow_ui_time_ms(); @@ -1341,7 +1531,7 @@ static void webtoon_release(void) if (pull <= -MR_BOOK_PULL && book_switch(-1)) return; if (g_scroll) meow_scroll_fling(g_scroll); cont_touch(); - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } void reader_on_touch_down(int x, int y) @@ -1440,7 +1630,7 @@ void reader_on_touch_move(int x, int y) g_dragging = 0; g_drag_mode = 0; g_drag_off = 0; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); return; } @@ -1614,9 +1804,8 @@ static void reader_tick(void) if (now - g_last_tick < MR_TICK_MS) return; g_last_tick = now; - int had_current = slot_find(current_page()) != NULL; - if (results_drain() && !had_current && slot_find(current_page()) && !g_dragging) - meow_ui_invalidate(0, 0, g_vw, g_vh); + /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ + if (results_drain() && !g_dragging) invalidate_content(); /* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */ if (g_open_err && g_open_retry_at && now >= g_open_retry_at) { @@ -1628,7 +1817,7 @@ static void reader_tick(void) } if (g_fail_paint) { g_fail_paint = 0; - if (!g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + if (!g_dragging) invalidate_content(); } if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush(); @@ -1811,9 +2000,14 @@ static void reader_page_enter(meow_view *v, void *ctx) if (data && data[0]) mr_cache_init(data); } - /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ + /* 打开册:显式起始页(书库文件管理器模式点第 N 张)优先于续读记录; + * 否则恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); int page = mr_cont_get_page(g_dir, 0); + if (g_start_at != MR_READER_START_RESUME) { + page = g_start_at; + g_start_at = MR_READER_START_RESUME; + } open_book(g_dir, page, mode); ticker_start(); @@ -1842,6 +2036,9 @@ static void reader_page_leave(meow_view *v, void *ctx) mr_strip_free(&g_strip); g_strip_ok = 0; slots_free_all(); + free(g_meta); + g_meta = NULL; + g_start_at = MR_READER_START_RESUME; mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } @@ -1882,14 +2079,22 @@ const char *mr_reader_dir(void) return g_dir; } -void mr_reader_open_dir(const char *path) +void mr_reader_open_dir_at(const char *path, int start_index) { if (!path) return; snprintf(g_dir, sizeof g_dir, "%s", path); - meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); + /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ + g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME + : (start_index < 0 ? 0 : start_index); + meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); meow_view_page_push(ACT_READER); } +void mr_reader_open_dir(const char *path) +{ + mr_reader_open_dir_at(path, MR_READER_START_RESUME); +} + #ifdef MR_READER_HOST_TEST /* 宿主集成测试观察口(不改变任何状态)。 */ int mr_reader_ui_mode(void) { return g_mode; } @@ -1898,4 +2103,14 @@ int mr_reader_ui_count(void) { return g_count; } int mr_reader_ui_open_err(void) { return g_open_err; } int mr_reader_ui_jump_open(void) { return g_jump_open; } int mr_reader_ui_jump_pending(void) { return mr_jump_active(&g_jump); } +long mr_reader_ui_resident_kb(void) { return resident_bytes() / 1024; } +int mr_reader_ui_failed_pages(void) +{ + int n = 0; + for (int i = 0; i < g_count && i < MR_BOOK_MAX_PAGES; i++) + if (page_failed(i)) n++; + return n; +} +int mr_reader_ui_blit_draws(void) { return g_blit_draws; } +int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } #endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 411981b..55523ce 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -13,6 +13,15 @@ extern "C" { void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); +/* 书库 → 阅读器(文件管理器模式用):按目录内图片自然序从 start_index 开始。 + * - start_index >= 册页数:钳到末页;其它负数按 0 处理; + * - start_index == MR_READER_START_RESUME(-1):沿用该册续读记录(= mr_reader_open_dir)。 + * 显式下标优先于续读记录:用户在列表里点了某张图,就不该被上次的页码带走。 */ +#define MR_READER_START_RESUME (-1) +void mr_reader_open_dir_at(const char *dir, int start_index); +/* 保留旧入口:等价于 mr_reader_open_dir_at(dir, MR_READER_START_RESUME)。 */ +void mr_reader_open_dir(const char *dir); + #ifdef MR_READER_HOST_TEST /* 宿主集成测试专用:只读观察本页状态(不参与产品逻辑)。 */ int mr_reader_ui_mode(void); /* MR_MODE_PAGED / MR_MODE_WEBTOON */ @@ -20,6 +29,10 @@ int mr_reader_ui_page(void); /* 当前页(条漫=视口顶端所在页 int mr_reader_ui_count(void); /* 当前册页数 */ int mr_reader_ui_jump_open(void); /* 1=跳页滑块已展开 */ int mr_reader_ui_jump_pending(void);/* 1=跳页目标等待解码 */ +long mr_reader_ui_resident_kb(void);/* 驻留页图字节(KB) */ +int mr_reader_ui_failed_pages(void);/* 解码失败/超预算被跳过的页数 */ +int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ +int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ #endif #ifdef __cplusplus From ceb65dfd014337641591132888cae2bfdb9f8d20 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 18:28:52 +0800 Subject: [PATCH 17/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=E6=9D=A1?= =?UTF-8?q?=E6=BC=AB=E9=95=BF=E9=A1=B5/=E7=AB=96=E7=BF=BB=E6=AF=8F?= =?UTF-8?q?=E5=B8=A7=E9=87=8D=E9=87=87=E6=A0=B7=EF=BC=88=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E5=87=A0=E4=BD=95=E5=AF=B9=E9=BD=90=E6=8E=92=E7=89=88=EF=BC=89?= =?UTF-8?q?+=20=E5=88=A4=E6=8D=AE=E4=B8=8E=E6=B4=BB=E9=94=81=EF=BC=88M17?= =?UTF-8?q?=20=E8=AF=84=E5=AE=A1=20P1-1/P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审用真实 UI 链路实测:600x960、800x1280(长宽比 1.6)在翻页拖动与条漫模式 都是 blit+0 / scaled+5;「先适高再切条漫」scaled+10。根因是解码几何来源与排版几何 不同源:decode_long_side() 把长边钳到屏长边 720(600x960 解成 450x720),而 strip_page_height() 按屏宽算出 480x768;mode_set() 又不动 g_fit,适高/原始进条漫 100% 重采样。 修复: - 解码目标 = 排版真正要的显示框:新增 page_geometry_at()(条漫=strip 高度、宽=视口宽; 翻页=fit/zoom),解码计划与绘页/就绪判据同源。 - plan_decode_long() 去掉屏长边钳制,只保留 MR_PAGE_BYTES_CAP(3MB) 硬上界, 超长条漫按比例收缩(不再因为「高度 > 屏长边」就永远对不上)。 - slot 记录本次解码的 want 几何;slot_ready() 改为几何比较: fit/缩放/模式变化 → 重解一次(放大再缩回、适高→条漫回到纯 blit); 被 3MB 钳住的解码 want 不变 → 不再反复重解(大屏活锁)。 - 解码几何随 page_req 下发、随 page_res 返回:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 - 峰值估算把 downscale/缩解输出一并计入(P2-3)。 - 适宽长图(竖向单独溢出)不再算平移模式:1.6 比例页原先左右 tap/横拖会变成 「水平平移 0px」的空操作,页面根本翻不动;现在仍按翻页处理(原始档/横向溢出不变)。 - 窗口不再主动清窗口外的页图:0→1→0 来回翻不再反复重解同一页(140 页测试 decode 次数 21→14);容量仍由 MR_SLOTS + 字节淘汰兜底。 - state_reset 复位 g_meta/g_start_at(起始页在 on_enter 入口先取走),删重复注释。 测试:test_reader_perf 夹具改成 600x960(1.6)并补 800x1280、翻页拖动、条漫、 「适高→条漫」、双击放大缩回、静止无重解、大屏 1080x1920 无活锁;test_reader_many_pages 补 meow_ui_paint_full(绘制路径覆盖,P2-5)。 --- tuku/test/test_reader_many_pages.c | 14 ++ tuku/test/test_reader_perf.c | 244 +++++++++++++++++++++++++---- tuku/ui/reader/reader.c | 219 ++++++++++++++++++-------- tuku/ui/reader/reader.h | 3 + 4 files changed, 379 insertions(+), 101 deletions(-) diff --git a/tuku/test/test_reader_many_pages.c b/tuku/test/test_reader_many_pages.c index d81a1ba..e1fcf01 100644 --- a/tuku/test/test_reader_many_pages.c +++ b/tuku/test/test_reader_many_pages.c @@ -208,6 +208,10 @@ int main(void) mr_reader_ui_count(), BOOK_PAGES); CHECK(mr_reader_ui_page() == 0, "start at page 0 (got %d)", mr_reader_ui_page()); pump_ms(600); + int draws0 = mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws(); + meow_ui_paint_full(); + CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > draws0, + "draw path executed (paged, 140p book)"); /* ---- M2 从第 N 张开始 / 越界钳制 ---- */ meow_view_page_pop(); @@ -246,8 +250,11 @@ int main(void) int before = mr_reader_ui_page(); tap(40, 300); pump_ms(260); + meow_ui_paint_full(); /* 绘制路径覆盖(P2-5) */ if (mr_reader_ui_page() == before) ok_move = 0; } + CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > draws0, + "draw path executed across flips"); CHECK(ok_move, "12 consecutive flips advanced the page"); CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded on 140p book (%ldKB)", mr_reader_ui_resident_kb()); @@ -285,6 +292,13 @@ int main(void) reader_on_tap(sx + sw, slider_y); pump_ms(900); CHECK(mr_reader_ui_jump_pending() == 0, "jump landed in webtoon"); + { + int d0 = mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws(); + meow_ui_paint_full(); + meow_ui_paint_full(); /* 条漫绘制路径覆盖(P2-5) */ + CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > d0, + "draw path executed (webtoon)"); + } CHECK(mr_reader_ui_page() >= BOOK_PAGES - 3, "jumped near last page (got %d)", mr_reader_ui_page()); CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded in webtoon (%ldKB)", diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 14843f2..866481a 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -1,20 +1,28 @@ -/* test_reader_perf.c — 阅读页翻页热路径回归(需要 SDK 源码树,宿主跑真实 view/anim/img)。 +/* test_reader_perf.c — 阅读页绘页热路径回归(需要 SDK 源码树,宿主跑真实 view/anim/img)。 * * 用户反馈:「翻页很卡,横翻竖翻都卡」「40 页也卡」。真因是每帧一次 meow_ui_img_scaled: - * 解码尺寸(maxdim=屏长边) ≠ 显示尺寸(适宽/适高) → 每帧重采样 + 每帧 1.2MB 临时缓冲 - * malloc/free。修法:解码期直接按显示尺寸落地,翻页只做原生 blit。 + * 解码尺寸 ≠ 排版要的显示尺寸 → 每帧重采样 + 每帧 1MB 级临时缓冲 malloc/free。 + * 修法:解码期直接按**排版要的几何**落地(翻页=fit/zoom,条漫=strip 排版高度),翻页只 blit。 * - * 本测试用 600x800(3:4)合成 BMP 当页图,断言: - * P1 适宽:解码 480x640 == 显示框 → 绘页零重采样(scaled_draws 不增长); - * P2 连续翻页 10 次(预取窗口命中):每帧仍零重采样,且确实画了页(blit 增长); - * P3 切「适高」:重解到 540x720 后同样零重采样(fit 变化会按新几何重解,不残留缩放); - * P4 驻留内存有上界(<= 9MB:3~4 张显示尺寸页)。 + * 夹具刻意用 **600x960(长宽比 1.6)**:条漫常见比例,显示高度 768 > 屏长边 720—— + * 早期夹具用 600x800(1.33)恰好绕过「钳到屏长边」的边界,给过过于乐观的信号(评审 P1-1)。 + * + * 断言(每项都看 blit/scaled 计数增量): + * P1 适宽进入:blit > 0 / scaled = 0; + * P2 连续翻页(点击来回 10 次):scaled 不增长; + * P3 翻页模式**跟手拖动**:scaled 不增长且确实画了页; + * P4 条漫模式(切换后滚动):scaled 不增长、blit 增长; + * P5 「先点适高,再切条漫」:scaled 不增长(评审点名的第二入口); + * P6 双击放大再双击缩回:缩回后按新几何重解 → scaled 不增长(P2-1); + * P7 静止若干帧不产生新解码(无重解抖动/活锁,P2-2); + * P8 驻留页图有字节上界(<= 9MB)。 * * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 */ #include "tuku_internal.h" #include "ui/reader/reader.h" #include "meow_ui_internal.h" +#include "logic/reader/nav.h" #include #include @@ -23,6 +31,9 @@ #include #include +#define PAGE_W 600 +#define PAGE_H 960 /* 长宽比 1.6:适宽显示 480x768(高 > 屏长边 720) */ + void mr_nav_report(void) {} static int g_fail; @@ -38,7 +49,6 @@ static void put32(unsigned char *p, unsigned v) p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); } -/* 合成 BMP24(stb 可解);填一点非零像素,避免全 0 触发任何「透明」特判。 */ static int write_bmp24(const char *path, int w, int h, unsigned seed) { int stride = ((w * 3 + 3) / 4) * 4; @@ -117,11 +127,23 @@ static void pump_ms(int ms) } } +/* 单击(越过双击判定窗口,确保单击动作生效)。 */ static void tap(int x, int y) { reader_on_touch_down(x, y); reader_on_tap(x, y); - pump_ms(300); /* 越过双击判定窗口,让单击生效 */ + pump_ms(300); +} + +/* 真双击:两次点击间隔 < MR_DOUBLE_MS(240ms)。 */ +static void double_tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(60); + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(120); } static void draw_once(void) { meow_ui_paint_full(); } @@ -142,7 +164,21 @@ int main(void) for (int i = 0; i < 4; i++) { char p[MR_PATH_MAX]; snprintf(p, sizeof p, "%s/p%d.bmp", book, i + 1); - if (write_bmp24(p, 600, 800, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + if (write_bmp24(p, PAGE_W, PAGE_H, (unsigned)i) != 0) { + printf("SKIP: bmp write failed\n"); + return 0; + } + } + char book2[MR_PATH_MAX]; + snprintf(book2, sizeof book2, "./mr_perf_books_%ld/v2", (long)getpid()); + if (mkdir_p(book2) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + for (int i = 0; i < 3; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/q%d.bmp", book2, i + 1); + if (write_bmp24(p, 800, 1280, (unsigned)(i + 7)) != 0) { + printf("SKIP: bmp write failed\n"); + return 0; + } } meow_ui_opts o = { 480, 720 }; @@ -154,28 +190,28 @@ int main(void) meow_view_page_push(ACT_LIBRARY); int w = meow_ui_width(); - int fit_x = w - 10 - 48 * 3 + 24 + 48 * 2; /* 顶栏第 3 个动作 = fit */ + int mode_x = w - 10 - 48 * 3 + 24; /* 顶栏动作 0 = 模式 */ + int fit_x = mode_x + 48 * 2; /* 动作 2 = fit */ int bar_y = 28; - /* ---- P1 进入:适宽 480x640 == 解码尺寸 → 纯 blit ---- */ + /* ---- P1 进入(适宽):解码 480x768 == 显示框 → 纯 blit ---- */ mr_reader_open_dir(book); CHECK(mr_reader_ui_count() == 4, "book pages=%d", mr_reader_ui_count()); - pump_ms(700); + pump_ms(800); draw_once(); int b0 = mr_reader_ui_blit_draws(), s0 = mr_reader_ui_scaled_draws(); draw_once(); - CHECK(mr_reader_ui_blit_draws() > b0, "page drawn with native blit"); + CHECK(mr_reader_ui_blit_draws() > b0, "paged: page drawn with native blit"); CHECK(mr_reader_ui_scaled_draws() == s0, - "fit-width: zero resample per frame (scaled %d -> %d)", + "paged fit-width: zero resample (scaled %d -> %d)", s0, mr_reader_ui_scaled_draws()); - /* ---- P2 连续翻页:预取命中,仍零重采样 ---- */ + /* ---- P2 连续翻页 ---- */ int bt = mr_reader_ui_blit_draws(), st = mr_reader_ui_scaled_draws(); int pages_ok = 1; for (int i = 0; i < 10; i++) { int before = mr_reader_ui_page(); - /* RTL 默认:左 1/3=下一页、右 1/3=上一页;来回翻避免撞到首末页的册间衔接。 */ - tap(i % 2 ? w - 40 : 40, 300); + tap(i % 2 == 0 ? 40 : w - 40, 300); /* RTL:左 1/3=下一页、右 1/3=上一页 */ pump_ms(350); if (mr_reader_ui_page() == before) pages_ok = 0; draw_once(); @@ -186,31 +222,173 @@ int main(void) CHECK(mr_reader_ui_scaled_draws() == st, "flip hot path: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); - /* ---- P3 切「适高」:按新几何重解后再测 ---- */ - tap(fit_x, bar_y); /* 适宽 → 适高 */ - pump_ms(600); - draw_once(); - int s2 = mr_reader_ui_scaled_draws(); - for (int i = 0; i < 5; i++) { - tap(i % 2 ? w - 40 : 40, 300); - pump_ms(350); + /* ---- P3 翻页模式跟手拖动(评审实测的 scaled 路径)---- */ + st = mr_reader_ui_scaled_draws(); + bt = mr_reader_ui_blit_draws(); + reader_on_touch_down(400, 300); + for (int i = 1; i <= 8; i++) { + reader_on_touch_move(400 - i * 25, 300); draw_once(); } - CHECK(mr_reader_ui_scaled_draws() == s2, - "fit-height: re-decoded page still zero resample (scaled %d -> %d)", - s2, mr_reader_ui_scaled_draws()); + reader_on_tap(200, 300); /* 松手(未过阈值 → 回弹) */ + pump_ms(300); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > bt, "paged drag drew pages (%d blits)", + mr_reader_ui_blit_draws() - bt); + CHECK(mr_reader_ui_scaled_draws() == st, + "paged drag: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); - /* ---- P4 驻留内存上界 ---- */ + /* ---- P4 条漫模式:切换后滚动,长宽比 1.6 页也必须零重采样 ---- */ + tap(mode_x, bar_y); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon"); + pump_ms(800); + draw_once(); + st = mr_reader_ui_scaled_draws(); + bt = mr_reader_ui_blit_draws(); + reader_on_touch_down(240, 600); + for (int i = 1; i <= 8; i++) { + reader_on_touch_move(240, 600 - i * 30); + draw_once(); + } + reader_on_tap(240, 300); + pump_ms(400); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > bt, "webtoon scroll drew pages (%d blits)", + mr_reader_ui_blit_draws() - bt); + CHECK(mr_reader_ui_scaled_draws() == st, + "webtoon (1.6 page): zero resample (scaled %d -> %d)", st, + mr_reader_ui_scaled_draws()); + + /* ---- P5 「先适高、再切条漫」:strip 恒按屏宽排版,解码必须跟随 ---- */ + tap(mode_x, bar_y); /* 回翻页 */ + CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "back to paged"); + pump_ms(500); + tap(fit_x, bar_y); /* 适宽 → 适高 */ + CHECK(mr_reader_ui_fit() == MR_FIT_HEIGHT, "fit-height selected (got %d)", + mr_reader_ui_fit()); + pump_ms(700); + tap(mode_x, bar_y); /* 适高档 → 条漫 */ + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon after fit-height"); + pump_ms(900); + draw_once(); + st = mr_reader_ui_scaled_draws(); + bt = mr_reader_ui_blit_draws(); + for (int i = 0; i < 5; i++) { + reader_on_touch_down(240, 600); + for (int k = 1; k <= 4; k++) { + reader_on_touch_move(240, 600 - k * 30); + draw_once(); + } + reader_on_tap(240, 300); + pump_ms(250); + draw_once(); + } + CHECK(mr_reader_ui_blit_draws() > bt, "webtoon after fit-height drew pages (%d blits)", + mr_reader_ui_blit_draws() - bt); + CHECK(mr_reader_ui_scaled_draws() == st, + "fit-height -> webtoon: zero resample (scaled %d -> %d)", st, + mr_reader_ui_scaled_draws()); + + /* ---- P6 双击放大再缩回(评审 P2-1:缩回后必须按新几何重解)---- */ + tap(mode_x, bar_y); /* 回翻页 */ + CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "paged for zoom test"); + pump_ms(600); + draw_once(); + double_tap(240, 360); /* 放大 */ + pump_ms(700); + draw_once(); + double_tap(240, 360); /* 缩回 */ + pump_ms(800); + draw_once(); + st = mr_reader_ui_scaled_draws(); + for (int i = 0; i < 5; i++) draw_once(); + CHECK(mr_reader_ui_scaled_draws() == st, + "zoom out: page re-decoded at fit geometry, zero resample (scaled %d -> %d)", + st, mr_reader_ui_scaled_draws()); + + /* ---- P7 静止不抖:无新解码(重解活锁回归,P2-2)---- */ + pump_ms(300); + int d0 = mr_reader_ui_decode_count(); + for (int i = 0; i < 10; i++) { draw_once(); pump_ms(40); } + CHECK(mr_reader_ui_decode_count() == d0, + "idle: no extra decodes (%d -> %d)", d0, mr_reader_ui_decode_count()); + + /* ---- P8 驻留内存上界 ---- */ long kb = mr_reader_ui_resident_kb(); - printf(" info: resident=%ldKB blit=%d scaled=%d\n", kb, - mr_reader_ui_blit_draws(), mr_reader_ui_scaled_draws()); + printf(" info: 600x960 resident=%ldKB blit=%d scaled=%d decodes=%d\n", kb, + mr_reader_ui_blit_draws(), mr_reader_ui_scaled_draws(), + mr_reader_ui_decode_count()); CHECK(kb <= 9 * 1024, "resident page images bounded (got %ldKB)", kb); + /* ---- P9 800x1280(评审表格另一档):翻页 + 条漫都必须零重采样 ---- */ + meow_view_page_pop(); + mr_reader_open_dir(book2); + pump_ms(800); + draw_once(); + st = mr_reader_ui_scaled_draws(); + bt = mr_reader_ui_blit_draws(); + reader_on_touch_down(400, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(400 - i * 25, 300); + draw_once(); + } + reader_on_tap(200, 300); + pump_ms(300); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > bt && mr_reader_ui_scaled_draws() == st, + "800x1280 paged drag: zero resample (blit +%d, scaled +%d)", + mr_reader_ui_blit_draws() - bt, mr_reader_ui_scaled_draws() - st); + tap(mode_x, bar_y); /* 条漫 */ + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "800x1280 webtoon"); + pump_ms(800); + draw_once(); + st = mr_reader_ui_scaled_draws(); + bt = mr_reader_ui_blit_draws(); + for (int i = 0; i < 5; i++) { + reader_on_touch_down(240, 600); + for (int k = 1; k <= 4; k++) { + reader_on_touch_move(240, 600 - k * 30); + draw_once(); + } + reader_on_tap(240, 300); + pump_ms(250); + draw_once(); + } + CHECK(mr_reader_ui_blit_draws() > bt, + "800x1280 webtoon drew pages (%d blits)", mr_reader_ui_blit_draws() - bt); + CHECK(mr_reader_ui_scaled_draws() == st, + "800x1280 webtoon: zero resample (scaled %d -> %d)", st, + mr_reader_ui_scaled_draws()); + meow_view_page_pop(); meow_view_free(reader); meow_view_free(lib); meow_ui_shutdown(); + + /* ---- P10 大屏(1080x1920):fit 目标 >3MB 被字节上限钳住,不得反复重解(P2-2)---- */ + meow_ui_opts o2 = { 1080, 1920 }; + if (meow_ui_init(&o2) == 0) { + lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + mr_reader_open_dir(book); + pump_ms(1200); + meow_ui_paint_full(); + int dbig = mr_reader_ui_decode_count(); + for (int i = 0; i < 10; i++) { meow_ui_paint_full(); pump_ms(40); } + CHECK(mr_reader_ui_decode_count() == dbig, + "large screen: clamped decode is stable, no live-lock (%d -> %d)", + dbig, mr_reader_ui_decode_count()); + printf(" info: large screen resident=%ldKB decodes=%d\n", + mr_reader_ui_resident_kb(), mr_reader_ui_decode_count()); + meow_view_page_pop(); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + } + rm_rf(book); + rm_rf(book2); char rootdir[MR_PATH_MAX]; snprintf(rootdir, sizeof rootdir, "./mr_perf_books_%ld", (long)getpid()); rm_rf(rootdir); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 04d9d4b..442c3e6 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -11,9 +11,10 @@ * meta() 不解码像素;解码入口走 SDK M12 预算解码 meow_img_load_file_dim(JPEG 解码期缩解, * PNG/BMP 整幅解码后降采样——超大头先 meow_img_probe_file 预判,超过预算跳过并告警)。 * - * 性能(M17):解码目标 = **当前 fit/zoom 的显示尺寸**(不是屏长边),所以翻页/滚动热路径 - * 只有原生 blit:不做 meow_ui_img_scaled 的每帧重采样,也不再每帧 malloc 1.2MB 临时缓冲。 - * fit 变化时窗口按新几何重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 + * 性能(M17):解码目标 = **排版真正要的显示框**(翻页=fit/zoom;条漫=strip 排版高度, + * 宽恒为视口宽),不是屏长边,所以翻页/滚动热路径只有原生 blit:不做 meow_ui_img_scaled + * 的每帧重采样,也不再每帧 malloc 1MB 级临时缓冲。几何变化(fit/缩放/切模式)时按新几何 + * 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 * 内存:单页显示缓存 ≤3MB、整窗 ≤9MB(超限淘汰最远页),解码中间峰值 >12MB 的页直接跳过 * 并显示占位——页数增长不会线性吃内存(常驻只与解码窗大小有关)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 @@ -137,6 +138,7 @@ typedef struct page_slot { int idx; /* -1=空 */ meow_img *img; /* 已 downscale 的页图 */ int src_w, src_h; + int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */ } page_slot; static page_slot g_slot[MR_SLOTS]; @@ -148,8 +150,11 @@ static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8]; typedef struct page_meta { int w, h; } page_meta; static page_meta *g_meta; -/* 解码预算(屏长边):超过它的显示尺寸无法靠「再解一次」变大,用于避免重解死循环。 */ -static int g_decode_budget = 720; +/* 本次解码计划(主线程在 queue_page 里算好,经 page_req 传给 worker;worker 在 + * mr_book_open 前写入这几个静态量,解码回调只读它们 → 后台线程不再读 UI 全局几何)。 */ +static int g_plan_want_w, g_plan_want_h; /* 显示框(几何,未钳制) */ +static int g_plan_long; /* 实际解码长边(受单页字节上限钳制) */ +static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */ static int page_failed(int idx) { @@ -163,9 +168,20 @@ static void page_mark_failed(int idx) g_page_fail[idx >> 3] |= (unsigned char)(1u << (idx & 7)); } -/* ---- 后台解码队列 ---- */ -typedef struct page_req { int idx; unsigned gen; } page_req; -typedef struct page_res { int idx; unsigned gen; meow_img *img; int fail; } page_res; +/* ---- 后台解码队列(请求携带解码几何,结果带回同一条几何) ---- */ +typedef struct page_req { + int idx; + unsigned gen; + int want_w, want_h; /* 按哪个显示框解码(主线程算) */ + int long_side; /* 实际解码长边(≤ 单页字节上限) */ +} page_req; +typedef struct page_res { + int idx; + unsigned gen; + meow_img *img; + int fail; + int want_w, want_h; +} page_res; static pthread_t g_worker; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; @@ -244,22 +260,17 @@ static int path_is_jpeg(const char *path) return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0; } -/* 目标解码长边:优先=当前 fit/zoom 的显示尺寸(解码后可直接 blit,翻页零重采样), - * 再受屏长边预算与单页字节上限约束。pw/ph=原始尺寸。 */ -static int decode_long_side(int pw, int ph, int budget) +/* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit), + * 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。 + * 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */ +static int plan_decode_long(int want_w, int want_h) { - int tw = 0, th = 0; - int long0 = pw > ph ? pw : ph; - int short0 = pw > ph ? ph : pw; - if (mr_nav_fit_size(g_fit, pw, ph, g_vw, g_vh, &tw, &th) != 0) - return budget; - if (g_zoom_pct != 100) mr_nav_zoom_size(tw, th, g_zoom_pct, &tw, &th); - int l = tw > th ? tw : th; - if (l < 64) l = 64; - if (budget > 0 && l > budget) l = budget; - /* 单页缓存上限:长边按比例收到能装下为止(高度极大的长条漫走这里)。 */ + int l0 = want_w > want_h ? want_w : want_h; + int s0 = want_w > want_h ? want_h : want_w; + if (l0 <= 0) return 0; + int l = l0 < 64 ? 64 : l0; while (l > 64) { - int s = (int)((long long)short0 * l / long0); + int s = (int)((long long)s0 * l / l0); if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break; l = l * 3 / 4; } @@ -271,30 +282,37 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) (void)ud; if (!path || !path[0]) return NULL; + /* 解码计划由 queue_page() 在主线程算好(want 显示框 + 实际长边),worker/主线程同步兜底 + * 路径在 mr_book_open 前写入;无计划时退回旧预算 maxdim。 */ + int want_w = g_plan_want_w, want_h = g_plan_want_h; + int dim = g_plan_long > 0 ? g_plan_long : maxdim; + /* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。 * - 解码中间缓冲峰值超上限 → 跳过(页面显示占位),避免真机被 OOM 杀掉(闪退); + * - 峰值 = 解码缓冲 + 随后的 downscale 输出(≤单页缓存上限),两者会同时驻留; * - 非缩解格式(PNG/BMP)原始 RGBA 超过告警线 → 记 WARN(残余风险)。 */ int pw = 0, ph = 0, ch = 0; int have = meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0; - int dim = maxdim; if (have) { int l0 = pw > ph ? pw : ph; - dim = decode_long_side(pw, ph, maxdim); - long long peak; + long long out_bytes = MR_PAGE_BYTES_CAP; /* downscale/缩解输出上限 */ + if (want_w > 0 && want_h > 0 && (long long)want_w * want_h * 4 < out_bytes) + out_bytes = (long long)want_w * want_h * 4; + long long decode_bytes; if (path_is_jpeg(path)) { /* 估 stbi 缩解后的输出(与 SDK 的档位选择同式):仍超上限才跳过。 */ int s = 1; while (s < 8 && (l0 / (s * 2)) >= dim) s *= 2; - peak = (long long)(pw / s) * (ph / s) * 4; + decode_bytes = (long long)(pw / s) * (ph / s) * 4; } else { - peak = (long long)pw * ph * 4; /* 整幅解码后降采样 */ - if (peak > MR_LOAD_RAW_WARN) + decode_bytes = (long long)pw * ph * 4; /* 整幅解码后降采样 */ + if (decode_bytes > MR_LOAD_RAW_WARN) meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s", - pw, ph, peak / 1024, path); + pw, ph, decode_bytes / 1024, path); } - if (peak > MR_DECODE_PEAK_CAP) { + if (decode_bytes + out_bytes > MR_DECODE_PEAK_CAP) { meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (peak %lldKB > cap): %s", - pw, ph, peak / 1024, path); + pw, ph, (decode_bytes + out_bytes) / 1024, path); return NULL; } } @@ -402,6 +420,21 @@ static void page_target_size_at(int idx, int *w, int *h) if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); } +/* 解码/绘制的目标显示框:条漫 = strip 排版几何(宽恒为视口宽、高由排版表定), + * 翻页 = fit/zoom。解码计划与「缓存是否就绪」必须用同一个函数:否则解出来的尺寸 + * 和排版要的尺寸不同源,条漫长页(长宽比 > 屏高/屏宽)就每帧重采样(M17 评审 P1-1)。 */ +static void page_geometry_at(int idx, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!w || !h) return; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + int ph = mr_strip_page_h(&g_strip, idx); + if (ph > 0) { *w = g_vw; *h = ph; return; } + } + page_target_size_at(idx, w, h); +} + /* ============================================================ */ /* 续读状态(逐册 last page + mode;写入节流,避免滚动时频繁写卡) */ /* ============================================================ */ @@ -489,6 +522,7 @@ static void slot_clear(int i) g_slot[i].img = NULL; g_slot[i].idx = -1; g_slot[i].src_w = g_slot[i].src_h = 0; + g_slot[i].want_w = g_slot[i].want_h = 0; } static void slots_free_all(void) @@ -496,22 +530,20 @@ static void slots_free_all(void) for (int i = 0; i < MR_SLOTS; i++) slot_clear(i); } -/* 缓存图是否就是当前 fit/zoom 的显示尺寸(可直接 blit)。已达解码预算的图也算就绪, - * 否则「原始/放大」档会陷入「解不出目标尺寸 → 反复重解」。 */ +/* 缓存图是否按「当前显示框」解出来的:比较 slot 记录的 want 几何与当前几何。 + * 用几何(而不是 src 像素尺寸)判定,才能同时满足三件事: + * - fit/zoom/模式变化 → 几何变 → 重解一次(放大再缩回、适高→条漫都能回到纯 blit); + * - 解码被单页字节上限钳住(超长条漫/大屏)→ want 不变 → 不再反复重解(无活锁); + * - 源图本身小于显示框(SDK 不做上采样)→ want 不变 → 不会每 tick 重排。 */ static int slot_ready(int idx) { page_slot *s = slot_find(idx); if (!s) return 0; - int tw = 0, th = 0; - page_target_size_at(idx, &tw, &th); - if (tw <= 0 || th <= 0) return 1; - if (abs(tw - s->src_w) <= MR_SLOT_TOL && abs(th - s->src_h) <= MR_SLOT_TOL) return 1; - if (g_decode_budget > 0 && - (s->src_w >= g_decode_budget || s->src_h >= g_decode_budget)) return 1; - /* 已是原始分辨率(小图不放大,SDK 不做上采样):这就是能拿到的最好结果。 */ - int mw = 0, mh = 0; - if (page_meta_dims(idx, &mw, &mh) && s->src_w >= mw && s->src_h >= mh) return 1; - return 0; + int w = 0, h = 0; + page_geometry_at(idx, &w, &h); + if (w <= 0 || h <= 0) return 1; + if (s->want_w <= 0 || s->want_h <= 0) return 0; + return abs(w - s->want_w) <= MR_SLOT_TOL && abs(h - s->want_h) <= MR_SLOT_TOL; } /* 淘汰离焦点页最远的一张(不淘汰 focus 与 except)。返回被清掉的槽下标,无则 -1。 */ @@ -529,8 +561,9 @@ static int slot_evict_far(int except) return far; } -/* 放进缓存;同页旧图直接替换(fit/zoom 变了要换尺寸)。返回 1=已放入。 */ -static int slot_put(int idx, meow_img *img) +/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。 + * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */ +static int slot_put(int idx, meow_img *img, int want_w, int want_h) { if (!img || idx < 0) return 0; int i = -1, empty = -1; @@ -565,6 +598,8 @@ static int slot_put(int idx, meow_img *img) g_slot[i].img = img; g_slot[i].src_w = meow_img_w(img); g_slot[i].src_h = meow_img_h(img); + g_slot[i].want_w = want_w; + g_slot[i].want_h = want_h; return 1; } @@ -630,10 +665,19 @@ static void queue_page(int idx) { if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; if (slot_ready(idx) || page_failed(idx)) return; + /* 几何在这里(主线程)算好塞进请求:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 */ + int want_w = 0, want_h = 0; + page_geometry_at(idx, &want_w, &want_h); + if (want_w <= 0 || want_h <= 0) { want_w = g_vw; want_h = g_vh; } + int long_side = plan_decode_long(want_w, want_h); pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { - g_req[g_req_tail].idx = idx; - g_req[g_req_tail].gen = g_gen; + page_req *r = &g_req[g_req_tail]; + r->idx = idx; + r->gen = g_gen; + r->want_w = want_w; + r->want_h = want_h; + r->long_side = long_side; g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; pthread_cond_signal(&g_cv); @@ -654,13 +698,19 @@ static void *reader_worker(void *arg) g_req_n--; pthread_mutex_unlock(&g_lock); + /* 解码计划:本线程私有(单 worker),解码回调只读这三个量。 */ + g_plan_want_w = req.want_w; + g_plan_want_h = req.want_h; + g_plan_long = req.long_side; uint64_t t0 = meow_ui_time_ms(); meow_img *img = mr_book_open(&g_book, req.idx); uint64_t dt = meow_ui_time_ms() - t0; + g_decode_count++; int w = img ? meow_img_w(img) : 0; int h = img ? meow_img_h(img) : 0; - meow_log(MEOW_LOG_INFO, "reader decode page=%d ms=%llu %dx%d %s", - req.idx, (unsigned long long)dt, w, h, img ? "ok" : "fail"); + meow_log(MEOW_LOG_INFO, "reader decode page=%d want=%dx%d long=%d ms=%llu %dx%d %s", + req.idx, req.want_w, req.want_h, req.long_side, + (unsigned long long)dt, w, h, img ? "ok" : "fail"); pthread_mutex_lock(&g_lock); if (!g_stop && g_res_n < MR_RES_MAX) { @@ -668,6 +718,8 @@ static void *reader_worker(void *arg) g_res[g_res_n].gen = req.gen; g_res[g_res_n].img = img; g_res[g_res_n].fail = img ? 0 : 1; + g_res[g_res_n].want_w = req.want_w; + g_res[g_res_n].want_h = req.want_h; g_res_n++; img = NULL; /* 所有权已移交(失败时本就是 NULL) */ } @@ -710,7 +762,12 @@ static void queue_prune(void) int n = 0; for (int i = 0; i < g_req_n; i++) { page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; - if (r.gen == g_gen && in_window(r.idx)) keep[n++] = r; + if (r.gen != g_gen || !in_window(r.idx)) continue; + /* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */ + int w = 0, h = 0; + page_geometry_at(r.idx, &w, &h); + if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) continue; + keep[n++] = r; } for (int i = 0; i < n; i++) g_req[i] = keep[i]; g_req_head = 0; @@ -739,7 +796,8 @@ static int results_drain(void) continue; } if (in_window(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img) && page_visible(got[i].idx)) changed = 1; + if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h) && + page_visible(got[i].idx)) changed = 1; } else { meow_img_free(got[i].img); } @@ -747,13 +805,12 @@ static int results_drain(void) return changed; } -/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ /* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ static void window_update(void) { window_compute(); - for (int i = 0; i < MR_SLOTS; i++) - if (g_slot[i].idx >= 0 && !in_window(g_slot[i].idx)) slot_clear(i); + /* 不主动清窗口外的图:多留一页「回看缓存」,避免 0→1→0 来回翻时反复重解同一页。 + * 容量仍由 MR_SLOTS 与 slot_put 的字节淘汰兜底(最多 MR_SLOTS 张)。 */ queue_prune(); for (int i = 0; i < g_win_n; i++) queue_page(g_win_idx[i]); } @@ -862,17 +919,30 @@ static void clamp_pan(void) } /* 内容是否超出视口(原始档的方图/宽图即使不放大也需要平移)。 */ -static int content_overflow(void) +/* 横向是否真的溢出(横向平移才有意义)。适宽长图只会在**竖向**溢出:那属于「页面比 + * 屏幕略高」,不能因此把左右 tap / 横拖从翻页改成平移——否则 1.5 以上长宽比的页 + * 在适宽档完全翻不动(水平平移量本就是 0,点击变空操作)。 */ +static int content_h_overflow(void) { - int w, h; + int w = 0, h = 0; current_geom(&w, &h); - return w > g_vw || h > g_vh; + return w > g_vw; } -/* 平移模式:放大,或原始档溢出。此时单指拖动=平移,左右 tap=平移步进。 */ +/* 平移模式:放大、横向溢出(原始档宽图 / 适高宽图)、或原始档任意溢出。 + * 此时单指拖动=平移,左右 tap=平移步进。 + * 适宽/适高的**竖向单独溢出**不算(适宽长图常见:1.6 比例页在 480x720 上高 768); + * 若把它算成平移,左右 tap / 横拖会变成「水平平移 0px」的空操作 —— 页面直接翻不动。 */ static int pan_mode(void) { - return g_zoom_pct > 100 || content_overflow(); + if (g_zoom_pct > 100) return 1; + if (content_h_overflow()) return 1; + if (g_fit == MR_FIT_ORIGINAL) { + int w = 0, h = 0; + current_geom(&w, &h); + return h > g_vh; + } + return 0; } /* 画一页(带水平位移);解码尺寸==显示尺寸时走原生 blit(翻页热路径零重采样), @@ -1373,8 +1443,7 @@ static int open_book(const char *dir, int start_page, int mode) if (dir != g_dir) snprintf(g_dir, sizeof g_dir, "%s", dir); g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; - int maxdim = g_vw > g_vh ? g_vw : g_vh; - g_decode_budget = maxdim; + int maxdim = g_vw > g_vh ? g_vw : g_vh; /* 仅作无解码计划时的兜底预算 */ /* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约, * 下面的翻页/条漫消费代码完全不区分 provider。 */ int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim, @@ -1424,8 +1493,14 @@ static int open_book(const char *dir, int start_page, int mode) worker_start(); if (!g_worker_on && g_count > 0) { /* 线程创建失败:退化为主线程同步解起始页(保证不是纯白屏)。 */ + int ww = 0, wh = 0; + page_geometry_at(g_page, &ww, &wh); + if (ww <= 0 || wh <= 0) { ww = g_vw; wh = g_vh; } + g_plan_want_w = ww; + g_plan_want_h = wh; + g_plan_long = plan_decode_long(ww, wh); meow_img *first = mr_book_open(&g_book, g_page); - if (first) slot_put(g_page, first); + if (first) slot_put(g_page, first, ww, wh); meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); } slider_build(); @@ -1954,6 +2029,12 @@ static void reader_state_reset(void) g_tap_pending = 0; g_last_tap_ms = 0; g_quitting = 0; + /* 本册状态一律清空:g_meta 由 open_book 按新册重建(on_enter 早退也不串上一册); + * g_start_at 已在 reader_page_enter 入口取走,这里复位防残留。 */ + free(g_meta); + g_meta = NULL; + g_start_at = MR_READER_START_RESUME; + g_plan_want_w = g_plan_want_h = g_plan_long = 0; } static void reader_page_enter(meow_view *v, void *ctx) @@ -1962,6 +2043,7 @@ static void reader_page_enter(meow_view *v, void *ctx) c->active = 1; g_quitting = 0; + int start_at = g_start_at; /* 显式起始页只在本次进入生效 */ g_vw = meow_ui_width(); g_vh = meow_ui_height(); v->x = 0; @@ -2001,13 +2083,11 @@ static void reader_page_enter(meow_view *v, void *ctx) } /* 打开册:显式起始页(书库文件管理器模式点第 N 张)优先于续读记录; - * 否则恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ + * 否则恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 + * 显式起始页在入口处取走(start_at 局部量),state_reset 之后仍有效。 */ int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); - int page = mr_cont_get_page(g_dir, 0); - if (g_start_at != MR_READER_START_RESUME) { - page = g_start_at; - g_start_at = MR_READER_START_RESUME; - } + int page = start_at != MR_READER_START_RESUME ? start_at + : mr_cont_get_page(g_dir, 0); open_book(g_dir, page, mode); ticker_start(); @@ -2113,4 +2193,7 @@ int mr_reader_ui_failed_pages(void) } int mr_reader_ui_blit_draws(void) { return g_blit_draws; } int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } +int mr_reader_ui_decode_count(void) { return g_decode_count; } +int mr_reader_ui_fit(void) { return g_fit; } +int mr_reader_ui_chrome(void) { return g_chrome; } #endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 55523ce..f841316 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -33,6 +33,9 @@ long mr_reader_ui_resident_kb(void);/* 驻留页图字节(KB) */ int mr_reader_ui_failed_pages(void);/* 解码失败/超预算被跳过的页数 */ int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ +int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ +int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ +int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ #endif #ifdef __cplusplus From c85877b447de18bd3945a293e2e4171aabc9cd98 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 19:38:55 +0800 Subject: [PATCH 18/38] =?UTF-8?q?SDK:=20=E4=BF=AE=20meow=5Fscroll=5Fwrap?= =?UTF-8?q?=20=E6=B1=A0=E6=A7=BD=E6=B3=84=E6=BC=8F=EF=BC=88free=20?= =?UTF-8?q?=E5=BD=92=E8=BF=98=20wrap=20=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E6=A7=BD=EF=BC=89+=2064=20=E6=AC=A1=E5=9B=9E=E6=94=B6/?= =?UTF-8?q?=E4=BA=A4=E9=94=99/=E6=B1=A0=E6=BB=A1=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=EF=BC=88M19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meow_scroll_wrap 从 MEOW_SCROLL_POOL(=8) 个 wrap 上下文槽占一个,meow_scroll_free 只归还 会话槽、从不归还 wrap 槽也不清 ctx.w → 进程内最多成功 wrap 8 次,之后恒 NULL。图库 list_rebuild/load_dir 每次重建,进 8 个目录后列表丢惯性(g_scroll==NULL 走 scroll_by 兜底), os.meow.files 同样每次 reload 重建。 - meow_scroll 结构内记 wrap_slot(创建置 -1),wrap 时写入槽下标;free 时按该下标清 g_wrap_ctx[].w/lv 并归还 g_wrap_used,随后置 -1(二次 free 不触碰任何槽)。 直接 create 的实例 wrap_slot=-1,free 不误清别人的槽。 - 池满语义不变:8 个同时活跃时第 9 个 wrap 仍返回 NULL(不越界);free(NULL) 安全。 - test_meow_ui_scroll 补回归:64 次 wrap/free 全可用(修复前 8/64,修复后 64/64)、free 后 再 wrap、8 满 + 第 9 个 NULL + 释放其一即复用、两 wrap 交错 free 其一不串槽、直接 create 不误清槽、list→listview→list 槽复用不残留 kind、free(NULL)/二次 free。 --- sdk/meow_ui_scroll.c | 25 +++- sdk/test_meow_ui_scroll.c | 234 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 243 insertions(+), 16 deletions(-) diff --git a/sdk/meow_ui_scroll.c b/sdk/meow_ui_scroll.c index 6d76c9b..d3877b3 100644 --- a/sdk/meow_ui_scroll.c +++ b/sdk/meow_ui_scroll.c @@ -52,11 +52,18 @@ struct meow_scroll { int bounce; /* fling 边界回弹幅度(带符号;0=不回弹) */ int bounce_start; /* 回弹回位起点(= clamp 边界) */ int snap_to; /* snap 目标 */ + int wrap_slot; /* >=0:占用的 g_wrap_ctx 槽下标(-1=非 wrap 实例) */ }; static meow_scroll g_pool[MEOW_SCROLL_POOL]; static unsigned char g_used[MEOW_SCROLL_POOL]; +/* wrap 上下文槽:meow_scroll_wrap 占用一个,meow_scroll_free 必须归还(否则进程内最多 wrap + * MEOW_SCROLL_POOL 次后恒 NULL)。槽由 wrap 实例独占,free 时按 wrap_slot 精确归还。 */ +typedef struct { meow_widget *w; int lv; } wrap_ctx; +static wrap_ctx g_wrap_ctx[MEOW_SCROLL_POOL]; +static int g_wrap_used[MEOW_SCROLL_POOL]; + /* ---- 生命周期 ---- */ meow_scroll *meow_scroll_create(meow_scroll_set_fn set, meow_scroll_max_fn max, meow_scroll_get_fn get, meow_scroll_redraw_fn redraw, @@ -70,6 +77,7 @@ meow_scroll *meow_scroll_create(meow_scroll_set_fn set, meow_scroll_max_fn max, memset(s, 0, sizeof *s); s->set = set; s->max = max; s->get = get; s->redraw = redraw; s->ctx = ctx; s->tau_ms = MEOW_SCROLL_TAU; + s->wrap_slot = -1; return s; } } @@ -81,11 +89,20 @@ void meow_scroll_free(meow_scroll *s) if (s->anim) meow_anim_cancel(s->anim); s->anim = NULL; for (int i = 0; i < MEOW_SCROLL_POOL; i++) - if (&g_pool[i] == s) { g_used[i] = 0; return; } + if (&g_pool[i] == s) { + int ws = s->wrap_slot; + if (ws >= 0 && ws < MEOW_SCROLL_POOL) { /* wrap 实例:归还上下文槽 */ + g_wrap_ctx[ws].w = NULL; + g_wrap_ctx[ws].lv = 0; + g_wrap_used[ws] = 0; + } + s->wrap_slot = -1; /* 二次 free 时不再触碰任何槽 */ + g_used[i] = 0; + return; + } } /* ---- wrap:包一个 meow_widget 列表 ---- */ -typedef struct { meow_widget *w; int lv; } wrap_ctx; static void wrap_set(void *c, int off) { wrap_ctx *wc = c; if (wc->lv) meow_listview_set_scroll(wc->w, off); else meow_list_set_scroll(wc->w, off); } static int wrap_max(void *c) @@ -100,9 +117,6 @@ static void wrap_redraw(void *c) if (r[2] > 0 && r[3] > 0) meow_ui_flush_region(r[0], r[1], r[2], r[3]); /* 只刷视口区 */ } -static wrap_ctx g_wrap_ctx[MEOW_SCROLL_POOL]; -static int g_wrap_used[MEOW_SCROLL_POOL]; - meow_scroll *meow_scroll_wrap(meow_widget *list, int is_listview) { if (!list) return NULL; @@ -113,6 +127,7 @@ meow_scroll *meow_scroll_wrap(meow_widget *list, int is_listview) g_wrap_used[i] = 1; g_wrap_ctx[i].w = list; g_wrap_ctx[i].lv = is_listview ? 1 : 0; s->ctx = &g_wrap_ctx[i]; + s->wrap_slot = i; return s; } } diff --git a/sdk/test_meow_ui_scroll.c b/sdk/test_meow_ui_scroll.c index 9f5cfa8..d10cc83 100644 --- a/sdk/test_meow_ui_scroll.c +++ b/sdk/test_meow_ui_scroll.c @@ -45,17 +45,6 @@ static meow_widget *make_list(void) return lv; } -/* 喂一串向下(正)速度采样,模拟快速下划 */ -static void feed_drag(meow_scroll *s, int step, int n) -{ - uint64_t now = meow_ui_time_ms(); - for (int i = 0; i < n; i++) { - meow_scroll_track(s, step, now + (uint64_t)i * 16); - } - /* 最后用一个真实稍晚时刻的 track 让采样时间戳真实(保持一致钟) */ - meow_scroll_track(s, 0, meow_ui_time_ms()); -} - static void pump_until_idle(meow_scroll *s, int *max_off, int *mono_ok) { int prev = meow_scroll_offset(s); @@ -169,15 +158,238 @@ static void test_stop(void) meow_widget_free(lv); } +/* ============ 池槽归还回归(M19)============ + * meow_scroll_wrap 从 MEOW_SCROLL_POOL(=8) 个 wrap 上下文槽里占一个;旧实现 free 只归还 + * 会话槽、不归还 wrap 槽 → 进程内最多成功 wrap 8 次,之后恒 NULL(图库每次 load_dir 重建, + * 进 8 个目录后列表丢惯性)。以下用例覆盖:长循环回收、free 后重建、交错不串槽、 + * 池满仍返回 NULL、直接 create 不误清槽、free(NULL)/二次 free 安全。 */ +#define WRAP_POOL 8 /* = meow_ui_scroll.c 的 MEOW_SCROLL_POOL(内部常量,测试按契约固定) */ + +/* 连续 64 次 wrap/free:每次都要拿到可用会话,且绑定当前列表 */ +static void test_wrap_free_recycle(void) +{ + meow_widget *lv = make_list(); + int ok = 0, first_fail = -1; + for (int i = 0; i < 64; i++) { + meow_scroll *s = meow_scroll_wrap(lv, 0); + if (!s) { if (first_fail < 0) first_fail = i; continue; } + meow_list_set_scroll(lv, 0); + meow_scroll_track(s, 7, meow_ui_time_ms()); + if (meow_scroll_offset(s) == 7) ok++; + else if (first_fail < 0) first_fail = i; /* 串槽:set/get 不指向本列表 */ + meow_scroll_free(s); + } + CHECK(ok == 64, "64 wrap/free cycles all usable, ok=%d first_fail=%d", ok, first_fail); + meow_widget_free(lv); +} + +/* wrap → free → 同一列表再 wrap:必须重新可用 */ +static void test_wrap_after_free(void) +{ + meow_widget *lv = make_list(); + meow_scroll *s = meow_scroll_wrap(lv, 0); + CHECK(s != NULL, "wrap#1 same list"); + meow_scroll_free(s); + s = meow_scroll_wrap(lv, 0); + CHECK(s != NULL, "wrap#2 same list after free"); + if (s) { + meow_list_set_scroll(lv, 0); + meow_scroll_track(s, 12, meow_ui_time_ms()); + CHECK(meow_scroll_offset(s) == 12, "re-wrapped scroll works got %d", meow_scroll_offset(s)); + meow_scroll_free(s); + } + meow_widget_free(lv); +} + +/* 8 个同时活跃 → 第 9 个仍返回 NULL(池满不越界);释放其一的槽立刻可再用 */ +static void test_wrap_pool_full(void) +{ + meow_widget *lv[WRAP_POOL]; + meow_scroll *s[WRAP_POOL]; + int ok = 0; + for (int i = 0; i < WRAP_POOL; i++) { + lv[i] = make_list(); + s[i] = meow_scroll_wrap(lv[i], 0); + if (s[i]) ok++; + } + CHECK(ok == WRAP_POOL, "all %d wraps active, ok=%d", WRAP_POOL, ok); + + meow_widget *extra_lv = make_list(); + meow_scroll *extra = meow_scroll_wrap(extra_lv, 0); + CHECK(extra == NULL, "9th simultaneous wrap returns NULL (pool full), got %p", (void *)extra); + + /* 释放中间一个 → 槽立即回收;活着的实例仍各自绑定自己的列表 */ + meow_scroll_free(s[3]); + s[3] = NULL; + meow_scroll *again = meow_scroll_wrap(extra_lv, 0); + CHECK(again != NULL, "wrap succeeds right after freeing one slot"); + if (again) { + meow_list_set_scroll(extra_lv, 0); + meow_scroll_track(again, 9, meow_ui_time_ms()); + CHECK(meow_scroll_offset(again) == 9, "new wrap bound to its own list got %d", + meow_scroll_offset(again)); + meow_list_set_scroll(lv[5], 33); + CHECK(meow_scroll_offset(s[5]) == 33, "other live wrap keeps its own list got %d", + meow_scroll_offset(s[5])); + meow_scroll_free(again); + } + for (int i = 0; i < WRAP_POOL; i++) { + if (s[i]) meow_scroll_free(s[i]); + meow_widget_free(lv[i]); + } + meow_widget_free(extra_lv); +} + +/* 交错:两个 wrap 活跃 → free 其一 → 再 wrap,三者不串槽 */ +static void test_wrap_interleaved(void) +{ + meow_widget *a = make_list(), *b = make_list(), *c = make_list(); + meow_scroll *sa = meow_scroll_wrap(a, 0); + meow_scroll *sb = meow_scroll_wrap(b, 0); + CHECK(sa != NULL && sb != NULL, "two wraps (interleaved) a=%p b=%p", (void *)sa, (void *)sb); + if (!sa || !sb) { + meow_scroll_free(sa); meow_scroll_free(sb); + meow_widget_free(a); meow_widget_free(b); meow_widget_free(c); + return; + } + meow_list_set_scroll(a, 11); + meow_list_set_scroll(b, 22); + CHECK(meow_scroll_offset(sa) == 11 && meow_scroll_offset(sb) == 22, + "distinct ctx a=%d b=%d", meow_scroll_offset(sa), meow_scroll_offset(sb)); + + meow_scroll_free(sa); + CHECK(meow_scroll_offset(sb) == 22, "remaining wrap intact after freeing first got %d", + meow_scroll_offset(sb)); + + meow_scroll *sc = meow_scroll_wrap(c, 0); + CHECK(sc != NULL, "third wrap after freeing first"); + if (sc) { + meow_list_set_scroll(c, 0); + meow_scroll_track(sc, 5, meow_ui_time_ms()); + CHECK(meow_scroll_offset(sc) == 5, "third wrap bound to its own list got %d", + meow_scroll_offset(sc)); + CHECK(meow_scroll_offset(sb) == 22, "second wrap not clobbered by third got %d", + meow_scroll_offset(sb)); + meow_scroll_free(sc); + } + meow_scroll_free(sb); + meow_widget_free(a); meow_widget_free(b); meow_widget_free(c); +} + +/* ---- 直接 create(非 wrap)的回调:用于验证 free 不误清别人的 wrap 槽 ---- */ +static int g_direct_off; +static void direct_set(void *c, int off) { (void)c; g_direct_off = off; } +static int direct_max(void *c) { (void)c; return 1000; } +static int direct_get(void *c) { (void)c; return g_direct_off; } + +static void test_direct_create_free_keeps_wrap_slot(void) +{ + meow_widget *lv = make_list(); + meow_scroll *w = meow_scroll_wrap(lv, 0); + CHECK(w != NULL, "wrap before direct create"); + meow_scroll *d = meow_scroll_create(direct_set, direct_max, direct_get, NULL, NULL); + CHECK(d != NULL, "direct create"); + if (d) { + meow_scroll_track(d, 40, meow_ui_time_ms()); + CHECK(g_direct_off == 40, "direct scroll tracks got %d", g_direct_off); + meow_scroll_free(d); /* 非 wrap 实例:不得归还/清除任何 wrap 槽 */ + } + if (w) { + meow_list_set_scroll(lv, 0); + meow_scroll_track(w, 10, meow_ui_time_ms()); + CHECK(meow_scroll_offset(w) == 10, "wrap intact after freeing direct instance got %d", + meow_scroll_offset(w)); + meow_scroll_free(w); + } + meow_widget_free(lv); +} + +/* free(NULL) 安全;同一实例二次 free 不崩、不清活着的 wrap 槽 */ +static void test_wrap_free_null_and_double(void) +{ + meow_scroll_free(NULL); /* 必须安全 */ + + meow_widget *a = make_list(), *b = make_list(); + meow_scroll *sa = meow_scroll_wrap(a, 0); + meow_scroll *sb = meow_scroll_wrap(b, 0); + CHECK(sa != NULL && sb != NULL, "two wraps (double free) a=%p b=%p", (void *)sa, (void *)sb); + if (sb) meow_list_set_scroll(b, 44); + if (sa) meow_scroll_free(sa); + if (sa) meow_scroll_free(sa); /* 二次 free:重复清零,不得清掉 sb 的 ctx */ + if (sb) { + CHECK(meow_scroll_offset(sb) == 44, "live wrap intact after double free got %d", + meow_scroll_offset(sb)); + meow_scroll_free(sb); + } + meow_scroll *sc = meow_scroll_wrap(a, 0); + CHECK(sc != NULL, "wrap still available after free(NULL)/double free"); + if (sc) meow_scroll_free(sc); + meow_widget_free(a); meow_widget_free(b); +} + +/* 虚拟化列表行源(仅需 count 有效,不绘制) */ +static int lv_src2(int idx, meow_list_item *out, void *ctx) +{ + (void)ctx; + if (idx < 0 || idx >= COUNT) return 0; + memset(out, 0, sizeof *out); + out->title = "row"; + return 1; +} + +/* 槽复用不继承上一个 wrap 的 kind:list → listview → list 交替必须各自生效 + * (free 时清 lv 标记,wrap 时按参数重设;否则复用槽上的旧 kind 会让 set/get 打空) */ +static void test_wrap_slot_kind_reset(void) +{ + meow_widget *plain = make_list(); + meow_widget *vl = meow_listview_create(0, 0, 480, LIST_H, COUNT, lv_src2, NULL, + MEOW_LIST_SEL_SINGLE); + meow_listview_set_item_h(vl, ITEM_H); + + meow_scroll *s = meow_scroll_wrap(plain, 0); + CHECK(s != NULL, "wrap plain list"); + if (s) meow_scroll_free(s); + + s = meow_scroll_wrap(vl, 1); + CHECK(s != NULL, "wrap listview on recycled slot"); + if (s) { + meow_listview_set_scroll(vl, 0); + meow_scroll_track(s, 20, meow_ui_time_ms()); + CHECK(meow_scroll_offset(s) == 20, "listview wrapped scroll works got %d", + meow_scroll_offset(s)); + meow_scroll_free(s); + } + + s = meow_scroll_wrap(plain, 0); + CHECK(s != NULL, "wrap plain list again on recycled slot"); + if (s) { + meow_list_set_scroll(plain, 0); + meow_scroll_track(s, 15, meow_ui_time_ms()); + CHECK(meow_scroll_offset(s) == 15, "plain list after listview slot reuse got %d", + meow_scroll_offset(s)); + meow_scroll_free(s); + } + meow_widget_free(vl); + meow_widget_free(plain); +} + int main(void) { meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("init failed\n"); return 1; } + /* 池槽回归先跑:新建进程里第一个动 pool 的用例,便于对照「修复前 8/64、修复后 64/64」 */ + test_wrap_free_recycle(); test_track_follow(); test_fling_no_boundary(); test_fling_boundary_bounce(); test_snap(); test_stop(); + test_wrap_after_free(); + test_wrap_pool_full(); + test_wrap_interleaved(); + test_direct_create_free_keeps_wrap_slot(); + test_wrap_slot_kind_reset(); + test_wrap_free_null_and_double(); meow_ui_shutdown(); printf("scroll: %d pass / %d fail\n", g_pass, g_fail); return g_fail ? 1 : 0; From 64336f95d92fd480c58913db3c18b12b1e980443 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 19:12:32 +0800 Subject: [PATCH 19/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=B9=A6=E5=BA=93?= =?UTF-8?q?=E6=94=B9=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=99=A8=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20+=20=E5=85=B3=E9=97=AD=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=20+=20=E6=A0=B9=E9=94=81=E5=AE=9A/=E6=97=A0=E5=8D=A1=E5=AE=B9?= =?UTF-8?q?=E9=94=99=EF=BC=88M18=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈四件事一起改: - 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路 (探测/页数/封面/续读角标/懒算 worker/sr2 补位),列表 = 目录 + 图片 + .cbz/.zip(目录优先、组内自然序)。点目录=进入(空目录也进),点图片= mr_reader_open_dir_at(当前目录, 图片自然序下标),点档案册=打开档案册。 - 「文件夹浏览特别卡」→ 删除缩略图线程/磁盘 bmp 缓存/LRU/解码路径与行内 缩略图;首帧只做一次 opendir/readdir+lstat,副标题(类型·大小)扫描期 生成一次,行合成/滚动/点击零 IO 零格式化;去掉逐行 snap(自由滚动手感)。 2000 项目录扫描 ~2.8ms(opendir=1, lstat=2000),行合成 2.78ms/200 万次 (宿主实测;旧实现 2000 项 entry 含 512B cover,内存 2.7MB→1.7MB)。 - 「不要访问 SD 卡上级目录」→ 浏览根锁定 MR_ROOT_DIR:删除越根的顶栏 「上级」/library_go_parent,根隐藏顶栏返回,左缘返回到根=退出 App。 - 「没有 SD 卡不要报错」→ 根不可访问(未插卡/挂载失败/根是文件)时友好空态 「未检测到 SD 卡」+ 顶栏刷新/重进页面重试;子目录不可读才弹提示横幅。 集成陷阱:书库图片下标与阅读器目录册共享同一套过滤器(隐藏项/图片扩展名/ 普通文件/符号链接)与 mr_natural_cmp,test_browse_lazy 直接对比 mr_dir_book_init 顺序,test_open_book_host 断言「点 img10 就开 img10」。 reader 侧 M17 遗留:mr_reader_open_dir_at 在阅读页已激活时先 pop 再 push, 走完整 leave/enter(SDK 对同 view 重复 push 是 no-op 且会压重复 activity)。 回归:run_tests.sh / run_webtoon_tests.sh 全 PASS;交叉构建零警告。 --- tuku/README.md | 13 + tuku/app.c | 3 +- tuku/logic/library.c | 386 +++------------ tuku/logic/library.h | 152 ++---- tuku/test/run_tests.sh | 14 +- tuku/test/test_browse_lazy.c | 396 ++++------------ tuku/test/test_browse_nav.c | 67 ++- tuku/test/test_browse_perf.c | 110 +++-- tuku/test/test_browse_ui_host.c | 417 ++++++++-------- tuku/test/test_cbz.c | 126 ++--- tuku/test/test_cont_adjacent.c | 85 +--- tuku/test/test_library.c | 111 +++-- tuku/test/test_open_book_host.c | 151 +++--- tuku/tuku_internal.h | 13 +- tuku/ui/library/library.c | 814 ++++++-------------------------- tuku/ui/reader/reader.c | 9 + 16 files changed, 940 insertions(+), 1927 deletions(-) diff --git a/tuku/README.md b/tuku/README.md index 5361e54..e3dc6a2 100644 --- a/tuku/README.md +++ b/tuku/README.md @@ -7,6 +7,19 @@ ./pack_tuku.sh # 打包 → os.meow.picbrowser.mpk(根布局;未签名) ``` +## 浏览交互(文件管理器模型,M18) + +- 列表 = 当前目录一层的**子目录 + 受支持图片(png/jpg/jpeg/bmp)+ 档案册(.cbz/.zip)**; + 目录优先,组内自然序(大小写不敏感,同键按原始字节兜底,`A.jpg`/`a.jpg` 不抖动)。 +- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(下标与阅读器目录册同一套过滤器/比较器); + 点档案册 = 打开档案册(第 1 页/续读)。 +- 缩略图功能已关闭:行内只有类型图标 + 文件名 + 类型·大小;首帧只做单层 + `opendir/readdir + lstat`,滚动/点击路径不解码、不访问文件、不重排字符串。 +- 浏览根锁定 TF 卡根(`/usr/data/mnt/sd_0`):根目录不显示返回箭头,左缘返回 = 退出 App; + **不允许上溯到 SD 卡的上级目录**。 +- 根目录不可访问(未插卡/挂载失败)→ 友好空态「未检测到 SD 卡」;点顶栏刷新或重进页面即重扫, + 不弹错、不崩溃。 + ## 一键签名出包 ```sh diff --git a/tuku/app.c b/tuku/app.c index f4dfb68..1e56c25 100644 --- a/tuku/app.c +++ b/tuku/app.c @@ -5,8 +5,7 @@ * - 页面切换统一走 meow_view_page_push/pop(activity 栈 + on_enter/on_leave 唯一入口); * - 物理键不映射返回/确认(on_key 仅日志);屏底上滑由 SDK 系统手势接管,App 不拦截; * - 左缘返回:reader/settings=pop;lock 页自行消费;书库逐级上移, - * **TF 根=退出 App**(系统返回语义,不再上溯系统目录);TF 根以外的上级浏览 - * 走顶栏显式「上级」动作。 + * **到 TF 卡根=退出 App**(浏览根锁定,不再上溯系统目录;顶栏也没有越根的「上级」)。 * * 主题同步:注册 meow_theme_add_listener;明暗/风格族切换时更新页面底色并整帧 invalidate, * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 diff --git a/tuku/logic/library.c b/tuku/logic/library.c index 7ab94c0..be142d7 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -1,28 +1,19 @@ -/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。 +/* logic/library.c — 书库文件管理器模型:单层枚举 + 纯内存行合成(纯 C)。 * - * 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构: - * - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环); - * - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出; - * - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 → - * 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。 + * 扫描(mr_library_scan)与 os.meow.files 的 fsx_scan_dir 同构: + * - 一次 opendir/readdir,逐项 lstat(只认目录、受支持图片、.cbz/.zip;符号链接跳过防环); + * - 隐藏项("." 开头,含 . ..)跳过;空目录/无图目录/系统目录照常列出; + * - 排序:目录优先,组内按 mr_natural_cmp 自然升序; + * - 副标题(类型 · 大小)在扫描期生成一次,渲染期不再做任何格式化/IO; + * - 条目到 MR_LIB_SCAN_MAX 后仍有条目 → 截断标注。 * - * 懒判定(probe): - * - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图; - * - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的 - * archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项 - * LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。 - * - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成, - * 书行再拼 M7 的「· 续读 Pn」角标。 - * - * M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数); - * 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。 + * 阅读器辅助(与列表模型解耦):mr_library_dir_has_images 单层判据 + + * mr_book_adjacent(_ex) 兄弟册衔接(M7/M11 阅读器翻到相邻册用)。 */ #include "library.h" -#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include -#include #include #include #include @@ -138,244 +129,39 @@ int mr_natural_cmp(const char *a, const char *b) return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); } -/* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */ -static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) -{ - if (!probe || !path || !path[0]) return 0; - int n = probe(path, ud); - return n > 0 ? n : 0; -} - /* ============================================================ */ -/* 单层探测 + mtime/size 失效缓存 */ +/* 扫描 / 排序 / 行合成 */ /* ============================================================ */ -int mr_library_probe_dir(const char *dir, mr_library_probe *out) +static void fmt_size(long long size, char *out, int outsz) { - if (!out) return -1; - memset(out, 0, sizeof *out); - if (!dir || !dir[0]) return -1; - - IO_OPEN(); - DIR *d = opendir(dir); - if (!d) return -1; - - char best[MR_NAME_MAX]; - best[0] = '\0'; - out->empty = 1; - struct dirent *de; - while ((de = readdir(d)) != NULL) { - IO_READDIR(); - const char *name = de->d_name; - if (name[0] == '.') continue; /* 隐藏项不算内容 */ - out->empty = 0; - if (!mr_path_is_image(name)) continue; - - char full[MR_PATH_MAX]; - if (join_path(full, (int)sizeof full, dir, name) != 0) continue; - IO_LSTAT(); - struct stat st; - if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; - - out->pages++; - if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) { - copy_str(best, (int)sizeof best, name); - copy_str(out->cover, (int)sizeof out->cover, full); - } - } - closedir(d); - return 0; + if (size < 0) size = 0; + static const char *const unit[] = { "B", "KB", "MB", "GB" }; + double v = (double)size; + int i = 0; + while (v >= 1024.0 && i < 3) { v /= 1024.0; i++; } + if (i == 0) snprintf(out, (size_t)outsz, "%lld B", size); + else snprintf(out, (size_t)outsz, "%.1f %s", v, unit[i]); } -#define MR_PROBE_CACHE_MAX 256 - -typedef struct probe_cache_entry { - char path[MR_PATH_MAX]; - long long mtime_ns; - long long size; - mr_library_probe probe; - unsigned long long last_use; -} probe_cache_entry; - -static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX]; -static unsigned long long g_cache_clock; -static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER; - -static long long stat_mtime_ns(const struct stat *st) +/* 副标题只在扫描期生成一次(渲染期零格式化)。 */ +static void format_subtitle(mr_library_entry *e) { -#if defined(__APPLE__) - return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec; -#else - return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec; -#endif -} - -static probe_cache_entry *cache_find_locked(const char *path) -{ - for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { - if (!g_cache[i]) continue; - if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i]; - } - return NULL; -} - -static void cache_put_locked(const char *path, long long mtime_ns, long long size, - const mr_library_probe *p) -{ - probe_cache_entry *e = cache_find_locked(path); - if (!e) { - int slot = -1; - for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) - if (!g_cache[i]) { slot = i; break; } - if (slot < 0) { /* 满:淘汰最久未用项 */ - int lru = 0; - unsigned long long oldest = g_cache[0]->last_use; - for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) { - if (g_cache[i]->last_use < oldest) { - oldest = g_cache[i]->last_use; - lru = i; - } - } - free(g_cache[lru]); - g_cache[lru] = NULL; - slot = lru; - } - e = (probe_cache_entry *)malloc(sizeof *e); - if (!e) return; /* 缓存只是加速:失败就每次现算 */ - if (copy_str(e->path, (int)sizeof e->path, path) != 0) { - free(e); - return; - } - g_cache[slot] = e; - } - e->mtime_ns = mtime_ns; - e->size = size; - e->probe = *p; - e->last_use = ++g_cache_clock; -} - -void mr_library_probe_cache_clear(void) -{ - pthread_mutex_lock(&g_cache_lock); - for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { - free(g_cache[i]); - g_cache[i] = NULL; - } - g_cache_clock = 0; - pthread_mutex_unlock(&g_cache_lock); -} - -/* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */ -static int probe_path_uncached(const char *path, int is_archive, - mr_lib_archive_fn archive_pages, void *ud, - mr_library_probe *out) -{ - struct stat st; - if (stat(path, &st) != 0) return -1; - if (is_archive) { - if (!archive_pages || !S_ISREG(st.st_mode)) return -1; - out->pages = archive_pages_at(archive_pages, ud, path); - out->empty = 0; - out->cover[0] = '\0'; - return 0; - } - if (!S_ISDIR(st.st_mode)) return -1; - return mr_library_probe_dir(path, out); -} - -int mr_library_probe_path(const char *path, int is_archive, - mr_lib_archive_fn archive_pages, void *ud, - mr_library_probe *out) -{ - if (!out) return -1; - memset(out, 0, sizeof *out); - if (!path || !path[0]) return -1; - - struct stat st; - if (stat(path, &st) != 0) return -1; - if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1; - - long long mtime_ns = stat_mtime_ns(&st); - long long size = (long long)st.st_size; - - pthread_mutex_lock(&g_cache_lock); - probe_cache_entry *hit = cache_find_locked(path); - if (hit && hit->mtime_ns == mtime_ns && hit->size == size) { - *out = hit->probe; - hit->last_use = ++g_cache_clock; - pthread_mutex_unlock(&g_cache_lock); - return 0; - } - pthread_mutex_unlock(&g_cache_lock); - - mr_library_probe p; - memset(&p, 0, sizeof p); - if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1; - - /* 评审 P2:只把「探测前后完全一致」的结果写入缓存。 - * 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整, - * 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */ - struct stat st2; - if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns && - (long long)st2.st_size == size) { - pthread_mutex_lock(&g_cache_lock); - cache_put_locked(path, mtime_ns, size, &p); - pthread_mutex_unlock(&g_cache_lock); - } - - *out = p; - return 0; -} - -int mr_library_probe_cached(const char *dir, mr_library_probe *out) -{ - return mr_library_probe_path(dir, 0, NULL, NULL, out); -} - -/* ============================================================ */ -/* 行状态:副标题 / 补位 / 行合成 */ -/* ============================================================ */ -/* 基础副标题(不含 M7 续读角标)。 */ -static void entry_subtitle(mr_library_entry *e) -{ - switch (e->probe_state) { - case MR_LIB_READY: - if (e->pages > 0) - snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, - e->pages == 1 ? "" : "s"); - else if (!e->is_archive && e->empty) - snprintf(e->subtitle, sizeof e->subtitle, "空目录"); - else - snprintf(e->subtitle, sizeof e->subtitle, "无图片"); + char sz[32]; + switch (e->kind) { + case MR_LIB_KIND_DIR: + snprintf(e->subtitle, sizeof e->subtitle, "文件夹"); break; - case MR_LIB_FAILED: - snprintf(e->subtitle, sizeof e->subtitle, "无法读取"); + case MR_LIB_KIND_ARCHIVE: + fmt_size(e->size, sz, (int)sizeof sz); + snprintf(e->subtitle, sizeof e->subtitle, "档案册 · %s", sz); break; default: - snprintf(e->subtitle, sizeof e->subtitle, "…"); + fmt_size(e->size, sz, (int)sizeof sz); + snprintf(e->subtitle, sizeof e->subtitle, "图片 · %s", sz); break; } } -/* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。 - * 档案册以档案路径为册标识,同一套逻辑复用。 - * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ -static void entry_badge(mr_library_entry *e) -{ - if (e->has_images != 1) return; - char badge[24]; - if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; - int used = (int)strlen(e->subtitle); - if (used < (int)sizeof e->subtitle - 2) - snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); -} - -void mr_library_format_subtitle(mr_library_entry *e) -{ - if (!e) return; - entry_subtitle(e); - entry_badge(e); -} - void mr_library_free(mr_library *lib) { if (!lib) return; @@ -400,59 +186,32 @@ static int lib_add(mr_library *lib, const mr_library_entry *e) return 0; } +/* 目录优先;组内自然序(大小写不敏感 + 原始字节兜底,结果确定)。 */ static int entry_cmp(const void *pa, const void *pb) { const mr_library_entry *a = (const mr_library_entry *)pa; const mr_library_entry *b = (const mr_library_entry *)pb; + int ad = (a->kind == MR_LIB_KIND_DIR), bd = (b->kind == MR_LIB_KIND_DIR); + if (ad != bd) return ad ? -1 : 1; return mr_natural_cmp(a->name, b->name); } -/* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */ -static int add_dir_entry(mr_library *lib, const char *name, const char *full) +static int add_entry(mr_library *lib, const char *name, const char *full, + int kind, const struct stat *st) { mr_library_entry e; memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - e.pages = -1; - e.has_images = -1; - e.probe_state = MR_LIB_PENDING; - mr_library_format_subtitle(&e); + e.kind = kind; + e.size = (kind == MR_LIB_KIND_DIR) ? 0 : (long long)st->st_size; + format_subtitle(&e); return lib_add(lib, &e); } -/* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。 - * pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。 - * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ -static int add_archive_entry(mr_library *lib, const char *name, const char *full, - int pages) +int mr_library_scan(mr_library *lib, const char *dir, int max_depth) { - mr_library_entry e; - memset(&e, 0, sizeof e); - if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; - if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - e.is_archive = 1; - if (pages < 0) { - e.pages = -1; - e.has_images = -1; - e.probe_state = MR_LIB_PENDING; - } else { - e.pages = pages; - e.has_images = (pages > 0) ? 1 : 0; - e.probe_state = MR_LIB_READY; - } - mr_library_format_subtitle(&e); - return lib_add(lib, &e); -} - -/* ============================================================ */ -/* 单层扫描 */ -/* ============================================================ */ -int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, - mr_lib_archive_fn archive_pages, void *ud) -{ - (void)max_depth; /* 兼容旧签名:扫描不再递归 */ - (void)ud; + (void)max_depth; /* 兼容旧签名:扫描不递归 */ if (!lib) return -1; mr_library_free(lib); if (!dir || !dir[0]) return -1; @@ -475,15 +234,15 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, if (lstat(full, &st) != 0) continue; if (S_ISLNK(st.st_mode)) continue; - int is_dir = S_ISDIR(st.st_mode); - int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) && - mr_path_is_archive(name)); - if (!is_dir && !is_arch) continue; - if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } + int kind; + if (S_ISDIR(st.st_mode)) kind = MR_LIB_KIND_DIR; + else if (!S_ISREG(st.st_mode)) continue; + else if (mr_path_is_image(name)) kind = MR_LIB_KIND_IMAGE; + else if (mr_path_is_archive(name)) kind = MR_LIB_KIND_ARCHIVE; + else continue; - int rc = is_dir ? add_dir_entry(lib, name, full) - : add_archive_entry(lib, name, full, -1); - if (rc != 0) { + if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } + if (add_entry(lib, name, full, kind, &st) != 0) { closedir(d); mr_library_free(lib); return -1; @@ -496,37 +255,6 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, return lib->count; } -int mr_library_scan(mr_library *lib, const char *dir, int max_depth) -{ - return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); -} - -int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p) -{ - if (!lib || !p || idx < 0 || idx >= lib->count) return 0; - mr_library_entry *e = &lib->items[idx]; - e->pages = p->pages; - e->empty = p->empty; - e->has_images = (p->pages > 0) ? 1 : 0; - e->probe_state = MR_LIB_READY; - copy_str(e->cover, (int)sizeof e->cover, p->cover); - mr_library_format_subtitle(e); - return 1; -} - -int mr_library_mark_error(mr_library *lib, int idx) -{ - if (!lib || idx < 0 || idx >= lib->count) return 0; - mr_library_entry *e = &lib->items[idx]; - e->pages = -1; - e->empty = 0; - e->has_images = 0; - e->probe_state = MR_LIB_FAILED; - e->cover[0] = '\0'; - mr_library_format_subtitle(e); - return 1; -} - int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) { static char trunc_sub[48]; @@ -551,12 +279,21 @@ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) memset(out, 0, sizeof *out); out->title = e->name; out->subtitle = e->subtitle; - out->is_book = (e->has_images == 1); - out->is_archive = e->is_archive; + out->kind = e->kind; out->index = idx; return 1; } +int mr_library_image_index(const mr_library *lib, int idx) +{ + if (!lib || idx < 0 || idx >= lib->count) return -1; + if (lib->items[idx].kind != MR_LIB_KIND_IMAGE) return -1; + int n = 0; + for (int i = 0; i < idx; i++) + if (lib->items[i].kind == MR_LIB_KIND_IMAGE) n++; + return n; +} + int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; @@ -582,16 +319,19 @@ int mr_path_parent(const char *path, char *out, int outsz) int mr_library_dir_has_images(const char *dir) { if (!dir || !dir[0]) return 0; + IO_OPEN(); DIR *d = opendir(dir); if (!d) return 0; int found = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { + IO_READDIR(); const char *name = de->d_name; if (name[0] == '.' || !mr_path_is_image(name)) continue; char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + IO_LSTAT(); struct stat st; if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; found = 1; @@ -618,7 +358,9 @@ static int sibling_is_book(const char *full, const struct stat *st, const char *slash = strrchr(full, '/'); const char *name = slash ? slash + 1 : full; if (!mr_path_is_archive(name)) return 0; - return archive_pages_at(probe, ud, full) > 0; + if (!probe) return 0; + int n = probe(full, ud); + return n > 0; } return 0; } diff --git a/tuku/logic/library.h b/tuku/logic/library.h index 4149c88..a4fd7d0 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -1,25 +1,18 @@ -/* logic/library.h — 书库目录浏览(纯 C,不依赖 meow_ui.h)。 +/* logic/library.h — 书库文件管理器模型(纯 C,不依赖 meow_ui.h)。 * * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 * - * M13 浏览语义(用户要求:性能 + 访问放开;参考 os.meow.files 的 fsx_scan_dir): - * - mr_library_scan / mr_library_scan_ex 只枚举**当前目录一层**:一次 opendir/readdir + - * 逐项 lstat,列出全部子目录(自然升序),不做任何递归「子树含图」探测; - * - 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, - * 空目录/无图目录一律列出并可进入;仅跳过 "."/".." 与其它 "." 开头的隐藏项、 - * 符号链接(防环); - * - 「直接含图目录=一本书」的判定改为**后台懒算**:mr_library_probe_dir 只读一层, - * mr_library_probe_path 按行类型(目录/档案册)走同一套 mtime 失效缓存(线程安全), - * 结果经 mr_library_apply_probe 补回行(页数/首图/副标题)。首帧/滚动不做子目录 IO; - * - 单目录条目上限 MR_LIB_SCAN_MAX(默认 2000):到上限后若还有条目则截断并置 - * lib->truncated=1(UI 用 mr_library_row_at 末尾的提示行标注)。 + * M18 浏览语义(用户要求「完全像文件管理器」): + * - 列表 = 当前目录一层的「子目录 + 受支持图片(png/jpg/jpeg/bmp) + 档案册(.cbz/.zip)」; + * 目录优先,组内自然序;其它普通文件不列;隐藏项("." 开头)与符号链接跳过; + * - **没有**「目录含图=一本书」的判定:不探测、不算页数、不取封面、不拼续读角标; + * 点目录就是进目录(空目录也进),点图片从该图开始读,点档案册打开档案册; + * - 扫描只做一次 opendir/readdir + 逐项 lstat;副标题(类型 · 大小)在扫描时生成一次, + * 渲染期零 IO、零字符串格式化(UI row 回调直接取指针); + * - 单目录条目上限 MR_LIB_SCAN_MAX:到上限后若还有条目则截断并置 lib->truncated=1。 * - * M11 档案册(.cbz/.zip):注入 archive_pages 回调时,当前层的档案文件也成行(与目录同列 - * 自然序混排);档案册的页数同样走懒探测(mr_library_probe_path + 注入回调),logic 层 - * 不依赖 zip 容器。未注入时(archive_pages==NULL)只列目录,等价 M7 书库。 - * - * M7 续读角标 / 册间衔接:mr_library_format_subtitle 在探测完成后拼「· 续读 Pn」 - * (mr_cont_badge);mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变。 + * 仍保留的阅读器辅助:mr_library_dir_has_images / mr_book_adjacent(_ex) + * (阅读到册末时翻到同父目录的相邻册,与书库列表模型无关)。 */ #ifndef MR_LIBRARY_H #define MR_LIBRARY_H @@ -30,149 +23,96 @@ extern "C" { #define MR_PATH_MAX 512 #define MR_NAME_MAX 256 -#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描已不递归,该参数被忽略 */ +#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描不递归,该参数被忽略 */ /* 单目录列出的条目上限(超大目录截断 + UI 提示;可用 -D 覆写便于宿主测试)。 */ #ifndef MR_LIB_SCAN_MAX #define MR_LIB_SCAN_MAX 2000 #endif -/* 行懒判定状态:0=待探测(副标题占位「…」)/1=已探测/2=探测失败(不可读)。 */ -#define MR_LIB_PENDING 0 -#define MR_LIB_READY 1 -#define MR_LIB_FAILED 2 +/* 行类型(列表图标/点击行为由 UI 按此分派)。 */ +#define MR_LIB_KIND_DIR 0 /* 子目录:点=进入 */ +#define MR_LIB_KIND_IMAGE 1 /* 受支持图片:点=从该图开始阅读 */ +#define MR_LIB_KIND_ARCHIVE 2 /* .cbz/.zip 档案册:点=打开档案册 */ typedef struct mr_library_entry { - char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */ - char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader/探测用) */ - char cover[MR_PATH_MAX]; /* 后台探测填:自然序第一张直接子图(档案册留空,UI 解压首图) */ - char subtitle[48]; /* 占位「…」;探测后 "N pages"/"空目录"/"无图片"/"无法读取" */ - int pages; /* -1=未判定;>=0=直接图片数(档案册=档内图片页数) */ - int subdirs; /* 兼容字段(M11 及旧读者):新扫描不统计,恒 0 */ - int has_images; /* 1=书;0=非书;-1=未判定 */ - int probe_state; /* MR_LIB_PENDING / MR_LIB_READY / MR_LIB_FAILED */ - int empty; /* 1=空目录(目录且 probe_state==READY 后有效) */ - int is_archive; /* 1=.cbz/.zip 档案册行 */ + char name[MR_NAME_MAX]; /* 文件名/目录名(行主标题) */ + char path[MR_PATH_MAX]; /* 完整路径 */ + char subtitle[64]; /* 扫描时预生成一次:「文件夹」/「图片 · 1.2 MB」/「档案册 · 1.2 MB」 */ + long long size; /* 普通文件字节数;目录=0 */ + int kind; /* MR_LIB_KIND_* */ } mr_library_entry; typedef struct mr_library { mr_library_entry *items; - int count; /* 当前层列出的条目数 */ + int count; /* 当前层列出的条目数 */ int cap; - int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */ + int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */ } mr_library; -/* 单行探测结果(目录=本层直接图片数;档案册=档内图片页数;均不递归)。 */ -typedef struct mr_library_probe { - int pages; /* 受支持图片数(png/jpg/jpeg/bmp;档案册=档内页数) */ - int empty; /* 1=目录没有可见子项(档案册恒 0) */ - char cover[MR_PATH_MAX]; /* 目录的自然序第一张图(档案册留空) */ -} mr_library_probe; - -/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO)。 */ +/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO、零格式化)。 */ typedef struct mr_library_row { - const char *title; /* 主标题 */ - const char *subtitle; /* 副标题(可空串) */ - int is_book; /* 1=书(显示页数/缩略图,点入阅读,无进入箭头) */ - int is_archive; /* 1=档案册行 */ - int disabled; /* 1=提示行(不可点,如截断标注) */ - int index; /* 对应 mr_library.items 下标;提示行=-1 */ + const char *title; /* 主标题 */ + const char *subtitle; /* 副标题(预生成,永不为 NULL) */ + int kind; /* MR_LIB_KIND_* */ + int disabled; /* 1=提示行(不可点,如截断标注) */ + int index; /* 对应 mr_library.items 下标;提示行=-1 */ } mr_library_row; /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ int mr_path_is_image(const char *name); -/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 - * 注意这只是路径判据;能不能当书读还要看档案里有没有图片页(探测回调)。 */ +/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 */ int mr_path_is_archive(const char *name); /* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; - * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ + * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序(结果确定,无抖动)。 */ int mr_natural_cmp(const char *a, const char *b); -/* 扫描 dir 的**直接子目录**(单层;不读子目录内容),写入 lib(先释放 lib 旧内容)。 +/* 扫描 dir 的**直接子项**(单层):子目录 + 受支持图片 + 档案册,写入 lib(先释放旧内容)。 * 返回条目数(>=0);dir 不可读/不存在/不是目录返回 -1(lib 仍为空且可安全 free)。 * max_depth 仅为兼容旧调用保留,已忽略(不再递归)。 */ int mr_library_scan(mr_library *lib, const char *dir, int max_depth); -/* ============================================================ */ -/* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */ -/* ------------------------------------------------------------ - * logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。 - * archive_pages(path, ud) 返回档内图片页数(>0=是一本书),0=不是/坏档。 - * 传 NULL 时行为与 M7 完全一致(纯目录书库,档案文件不成行)。 - * 档案册行:name=文件名、path=档案路径、is_archive=1、cover 留空(UI 解压首图)、 - * 页数/书标记由后台 probe 经同一注入回调懒算。 - * ============================================================ */ -typedef int (*mr_lib_archive_fn)(const char *path, void *ud); - -int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, - mr_lib_archive_fn archive_pages, void *ud); - /* 释放条目数组并清零(可重复调用)。 */ void mr_library_free(mr_library *lib); /* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。 - * 成功 0;参数非法/输出缓冲不足 -1。可一路向上到文件系统 '/'。 */ + * 成功 0;参数非法/输出缓冲不足 -1。纯路径工具,不做根目录限制(限制在 UI 导航层)。 */ int mr_path_parent(const char *path, char *out, int outsz); -/* ---- 后台懒判定(单层探测;可由工作线程调用) ---- */ - -/* 单层探测目录 dir:直接图片数 + 自然序首图 + 是否空目录。不递归、不跟随符号链接。 - * 0=成功;-1=参数非法/不可读(out 清零)。 */ -int mr_library_probe_dir(const char *dir, mr_library_probe *out); - -/* 带缓存探测目录:按目录 mtime(秒+纳秒)+ size 失效;内部互斥锁,可多线程并发调用。 - * 0=成功(可能来自缓存);-1=不可读(失败不缓存)。 */ -int mr_library_probe_cached(const char *dir, mr_library_probe *out); - -/* 按行探测(目录或档案册),带同一套缓存: - * is_archive=0 → 目录(等价 mr_library_probe_cached); - * is_archive=1 → 调 archive_pages(path,ud) 取档内页数(cover 留空、empty=0); - * archive_pages 为 NULL 时返回 -1。 - * 0=成功;-1=参数非法/不可读。探测期间 mtime/size 变化则不写入缓存(避免假新鲜)。 */ -int mr_library_probe_path(const char *path, int is_archive, - mr_lib_archive_fn archive_pages, void *ud, - mr_library_probe *out); - -/* 清空探测缓存(目录整体切换/测试用)。 */ -void mr_library_probe_cache_clear(void); - -/* 把探测结果补回第 idx 行(pages/has_images/cover/subtitle/empty)。 - * 1=已应用;0=参数非法/越界。 */ -int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p); - -/* 标记第 idx 行探测失败(不可读):副标题「无法读取」,视为非书。1=已应用;0=非法。 */ -int mr_library_mark_error(mr_library *lib, int idx); - -/* 按行当前状态重算副标题(占位/页数/空目录/无图片/无法读取), - * 并在书行(has_images==1)后缀「· 续读 Pn」(M7 角标)。 */ -void mr_library_format_subtitle(mr_library_entry *e); - /* 行合成(纯内存、零 IO、O(1);UI 线程每帧调用):idx 越界返回 0。 * idx==count 且 lib->truncated 时输出末尾提示行(disabled=1)。 * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); -/* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。 - * 1=有;0=没有/不可读。 */ +/* 图片行在该目录图片自然序中的下标(0 基);idx 不是图片行/越界返回 -1。 + * + * ⚠️ 这是书库→阅读器的契约:阅读器的目录册(logic/reader/page_source.c)只枚举 + * 「非隐藏 + mr_path_is_image + 普通文件」并按 mr_natural_cmp 排序。书库扫描用的是 + * 同一套过滤器与比较器,因此本函数数出的下标就是 mr_reader_open_dir_at() 的起始页。 */ +int mr_library_image_index(const mr_library *lib, int idx); + +/* 目录是否**直接**含受支持图片。1=有;0=没有/不可读。 + * 仅用于阅读器的「相邻册」判定,不再是书库行语义。 */ int mr_library_dir_has_images(const char *dir); -/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 +/* 册间衔接(M7):同父目录下按自然序排列的「直接含图」兄弟册。 * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 - * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 + * 语义:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 * 只算文件夹册(直接含图目录)——M7 口径不变。 */ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); /* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」, * 两者同列按名称自然序混排(跨册衔接按同父目录自然序)。 * archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */ +typedef int (*mr_lib_archive_fn)(const char *path, void *ud); int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); #ifdef MR_LIBRARY_TEST_HOOKS -/* 宿主测试用 IO 计数(scan/probe_dir 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ +/* 宿主测试用 IO 计数(scan/dir_has_images 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ typedef struct mr_library_io_stats { long opendirs; long readdirs; diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index f55bec8..dbdde77 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,9 +1,9 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:单层目录扫描/自然排序/父目录/阅读方向 -# - test_browse_nav:访问放开(系统/空/无图目录可进入)、上溯到 /、不可读目录、截断 -# - test_browse_lazy:单层探测/懒判定补位/mtime 失效缓存/行合成 O(1)/M7 册间语义 -# - test_browse_perf:500 目录×8 层嵌套+2000 文件夹具,断言扫描单层且有界 +# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/图片下标/父目录/阅读方向 +# - test_browse_nav:访问放开(系统/空/无图目录可进入)、图片与档案册成行、截断、不可读 +# - test_browse_lazy:行合成零 IO/副标题预生成/图片下标 == 阅读器目录册下标(集成陷阱) +# - test_browse_perf:500 目录×8 层嵌套 + 2000 项大目录,断言单层扫描与行合成成本 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) # - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C, @@ -33,7 +33,8 @@ gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ "$OUT"/test_browse_nav gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ - test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + test/test_browse_lazy.c logic/library.c logic/reader.c \ + logic/reader/page_source.c test/settings_reader_stub.c \ -pthread -o "$OUT/test_browse_lazy" "$OUT"/test_browse_lazy @@ -73,8 +74,9 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then -pthread -lm -o "$OUT"/test_settings_lock_host "$OUT"/test_settings_lock_host - # 书库浏览页集成(src2 懒算补位/行语义/上溯至 / /不可读提示);同样需要 SDK 源码树。 + # 书库浏览页集成(文件管理器行模型/点击语义/根锁定/无 SD 卡空态/零 IO 行合成);同样需要 SDK 源码树。 gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_LIBRARY_UI_TEST_HOOKS \ + -DMR_LIBRARY_TEST_HOOKS \ -I. -I"$SDK" -I"$SDK/include" \ test/test_browse_ui_host.c ui/library/library.c logic/library.c \ logic/reader.c logic/reader/page_source.c logic/reader/archive_source.c \ diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c index b6843c8..731048a 100644 --- a/tuku/test/test_browse_lazy.c +++ b/tuku/test/test_browse_lazy.c @@ -1,24 +1,22 @@ -/* test_browse_lazy.c — 单层探测 / 懒判定 / mtime 失效缓存 / 行合成(纯 C,无 SDK)。 +/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 图片下标一致性(纯 C,无 SDK)。 * - * 覆盖: - * L1 单层探测:直接图片数、自然序首图、空目录标记;不递归(只有子目录含图的目录不算书); - * L2 懒判定接线:scan 后行全部 PENDING(占位副标题)→ apply_probe 后页数/首图/副标题/ - * 书标记;空目录「空目录」;无图目录「无图片」;探测失败「无法读取」且视为非书; - * L3 缓存:mtime 失效(同 mtime 命中不读盘;目录变化后重扫并刷新结果);并发调用线程安全; - * L3b 评审 P2:探测期间 mtime/size 变化的结果不写缓存(避免「不完整结果绑定新 mtime」); - * L4 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; - * L5 M7 兼容语义:「直接含图兄弟册」+ 自然序册间顺序(M7 mr_book_adjacent 的口径)。 + * 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖: + * L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; + * L2 **书库→阅读器下标记号契约**:mr_library_image_index 数出的下标 == 阅读器目录册 + * (logic/reader/page_source.c: mr_dir_book_init)里同一张图的下标。 + * 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、 + * 前导零(page01/page1)、夹在图片之间的档案册与普通文件; + * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ - * test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * test/test_browse_lazy.c logic/library.c logic/reader/page_source.c \ * -pthread -o build/host/test_browse_lazy */ #include "logic/library.h" +#include "logic/reader/page_source.h" #include -#include -#include #include #include #include @@ -81,255 +79,47 @@ static void joinp(char *out, int outsz, const char *a, const char *b) snprintf(out, (size_t)outsz, "%s/%s", a, b); } -/* 把目录 mtime 显式推后 3 秒(避免同秒内变化被缓存误命中)。 */ -static void bump_mtime(const char *path) +static long long now_us(void) { - struct timespec ts[2]; - ts[0].tv_sec = time(NULL) + 3; - ts[0].tv_nsec = 0; - ts[1] = ts[0]; - utimensat(AT_FDCWD, path, ts, 0); + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000LL; } -static const mr_library_entry *find_entry(const mr_library *lib, const char *name) +/* 阅读器目录册用的假解码回调(本测试不打开任何页)。 */ +static meow_img *fake_load(const char *path, void *ud, int maxdim) { - for (int i = 0; i < lib->count; i++) - if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + (void)path; (void)ud; (void)maxdim; return NULL; } +static void fake_unload(meow_img *img) { (void)img; } -static int find_index(const mr_library *lib, const char *name, int *out) -{ - for (int i = 0; i < lib->count; i++) - if (strcmp(lib->items[i].name, name) == 0) { *out = i; return 1; } - *out = -1; - return 0; -} - -/* ---- L1 + L2 ---- */ -static void test_probe_and_apply(const char *root) -{ - char book[MR_PATH_MAX], noimg[MR_PATH_MAX], empty[MR_PATH_MAX], dirs_only[MR_PATH_MAX]; - char deep[MR_PATH_MAX]; - joinp(book, sizeof book, root, "book"); - joinp(noimg, sizeof noimg, root, "noimg"); - joinp(empty, sizeof empty, root, "empty"); - joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); - joinp(deep, sizeof deep, root, "dirs_only/deep"); - - mr_library_probe p; - CHECK(mr_library_probe_dir(book, &p) == 0, "probe book"); - CHECK(p.pages == 2, "book pages=%d", p.pages); - CHECK(p.empty == 0, "book not empty"); - CHECK(strstr(p.cover, "page2.png") != NULL, "cover natural-first: %s", p.cover); - - CHECK(mr_library_probe_dir(noimg, &p) == 0 && p.pages == 0 && p.empty == 0, - "noimg: 0 pages, not empty"); - CHECK(mr_library_probe_dir(empty, &p) == 0 && p.pages == 0 && p.empty == 1, - "empty dir flagged"); - CHECK(mr_library_probe_dir(dirs_only, &p) == 0 && p.pages == 0 && p.empty == 0, - "dirs_only has no direct images (no recursion)"); - CHECK(mr_library_probe_dir(deep, &p) == 0 && p.pages == 1, "deep is a book"); - - char slash[MR_PATH_MAX]; - snprintf(slash, sizeof slash, "%s/", book); - CHECK(mr_library_probe_dir(slash, &p) == 0 && p.pages == 2, "trailing slash"); - - CHECK(mr_library_probe_dir("/no/such/dir", &p) == -1, "missing dir -> -1"); - CHECK(mr_library_probe_dir(NULL, &p) == -1, "NULL -> -1"); - - mr_library lib; - memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) == 5, "scan 5 dirs (got %d)", lib.count); - - const mr_library_entry *e = find_entry(&lib, "book"); - CHECK(e && e->probe_state == MR_LIB_PENDING && e->pages == -1, - "book pending after scan"); - - int idx_book, idx_noimg, idx_empty, idx_dirs; - CHECK(find_index(&lib, "book", &idx_book), "book index"); - CHECK(find_index(&lib, "noimg", &idx_noimg), "noimg index"); - CHECK(find_index(&lib, "empty", &idx_empty), "empty index"); - CHECK(find_index(&lib, "dirs_only", &idx_dirs), "dirs_only index"); - if (idx_book < 0 || idx_noimg < 0 || idx_empty < 0 || idx_dirs < 0) { - mr_library_free(&lib); - return; - } - - CHECK(mr_library_probe_dir(lib.items[idx_book].path, &p) == 0, "probe row book"); - CHECK(mr_library_apply_probe(&lib, idx_book, &p) == 1, "apply book"); - e = &lib.items[idx_book]; - CHECK(e->has_images == 1 && e->pages == 2 && e->probe_state == MR_LIB_READY, - "book ready"); - CHECK(strcmp(e->subtitle, "2 pages") == 0, "book subtitle=%s", e->subtitle); - CHECK(strstr(e->cover, "page2.png") != NULL, "book row cover=%s", e->cover); - - CHECK(mr_library_probe_dir(lib.items[idx_noimg].path, &p) == 0 && - mr_library_apply_probe(&lib, idx_noimg, &p) == 1, "apply noimg"); - e = &lib.items[idx_noimg]; - CHECK(e->has_images == 0 && strcmp(e->subtitle, "无图片") == 0, - "noimg subtitle=%s", e->subtitle); - - CHECK(mr_library_probe_dir(lib.items[idx_empty].path, &p) == 0 && - mr_library_apply_probe(&lib, idx_empty, &p) == 1, "apply empty"); - e = &lib.items[idx_empty]; - CHECK(e->has_images == 0 && e->empty == 1 && strcmp(e->subtitle, "空目录") == 0, - "empty subtitle=%s", e->subtitle); - - CHECK(mr_library_mark_error(&lib, idx_dirs) == 1, "mark error"); - e = &lib.items[idx_dirs]; - CHECK(e->probe_state == MR_LIB_FAILED && e->has_images == 0 && - strcmp(e->subtitle, "无法读取") == 0, "failed subtitle=%s", e->subtitle); - - CHECK(mr_library_apply_probe(&lib, 999, &p) == 0, "apply out of range"); - CHECK(mr_library_mark_error(&lib, -1) == 0, "mark_error out of range"); - - mr_library_free(&lib); -} - -/* 档案册探测回调(P2 用例):可注入「探测期间文件变化」。 */ -typedef struct arch_probe_ctx { - int calls; - int bump_times; /* 前 N 次调用里改动文件 mtime(模拟探测期间变化) */ -} arch_probe_ctx; - -static int arch_probe(const char *path, void *ud) -{ - arch_probe_ctx *c = (arch_probe_ctx *)ud; - c->calls++; - if (c->calls <= c->bump_times) bump_mtime(path); - return 1; -} - -/* ---- L3b:评审 P2——探测期间 mtime 变化不得写入缓存(消除假新鲜) ---- */ -static void test_cache_race(const char *root) -{ - char arc[MR_PATH_MAX]; - joinp(arc, sizeof arc, root, "race.cbz"); - CHECK(touch_file(arc) == 0, "create race.cbz"); - - mr_library_probe_cache_clear(); - mr_library_probe p; - - /* 稳定文件:第二次走缓存,回调只调一次。 */ - arch_probe_ctx stable = { 0, 0 }; - CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, - "archive probe"); - CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, - "archive probe (cached)"); - CHECK(stable.calls == 1, "stable archive cached (calls=%d)", stable.calls); - - /* 探测期间文件变化:本次结果不得入缓存 → 下一次仍重新探测。 */ - mr_library_probe_cache_clear(); - arch_probe_ctx race = { 0, 1 }; - CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, - "racing archive probe"); - CHECK(race.calls == 1, "racing probe called once (calls=%d)", race.calls); - CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, - "racing archive probe again"); - CHECK(race.calls == 2, "changed-during-probe result NOT cached (calls=%d)", race.calls); - /* 第二次稳定后落缓存,第三次命中。 */ - CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, - "racing archive probe third"); - CHECK(race.calls == 2, "stable result cached after third call (calls=%d)", race.calls); - mr_library_probe_cache_clear(); -} - -/* ---- L3:mtime 失效缓存 + 并发 ---- */ -#define THREADS 6 -typedef struct th_arg { - const char *dir; - int want; - int *fail; - pthread_mutex_t *mu; -} th_arg; - -static void *probe_thread(void *arg) -{ - th_arg *a = (th_arg *)arg; - for (int i = 0; i < 200; i++) { - mr_library_probe p; - int ok = (mr_library_probe_cached(a->dir, &p) == 0 && p.pages == a->want); - if (!ok) { - pthread_mutex_lock(a->mu); - (*a->fail)++; - pthread_mutex_unlock(a->mu); - } - } - return NULL; -} - -static void test_cache(const char *root) -{ - char book[MR_PATH_MAX]; - joinp(book, sizeof book, root, "book"); - - mr_library_probe_cache_clear(); - mr_library_io_stats st; - mr_library_probe p; - - mr_library_test_io_reset(); - CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache miss probe"); - mr_library_test_io_get(&st); - CHECK(st.opendirs == 1, "cache miss opens once (got %ld)", st.opendirs); - - mr_library_test_io_reset(); - CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache hit"); - mr_library_test_io_get(&st); - CHECK(st.opendirs == 0 && st.lstats == 0, "cache hit does no IO (open=%ld stat=%ld)", - st.opendirs, st.lstats); - - char img[MR_PATH_MAX]; - joinp(img, sizeof img, book, "page30.png"); - CHECK(touch_file(img) == 0, "add image"); - bump_mtime(book); - mr_library_test_io_reset(); - CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 3, - "cache invalidated by mtime (pages=%d)", p.pages); - mr_library_test_io_get(&st); - CHECK(st.opendirs == 1, "invalidation re-opens (got %ld)", st.opendirs); - - CHECK(mr_library_probe_cached("/no/such/dir", &p) == -1, "missing dir -> -1"); - - pthread_t th[THREADS]; - th_arg args[THREADS]; - int failures = 0; - pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER; - int started = 0; - for (int t = 0; t < THREADS; t++) { - args[t].dir = book; - args[t].want = 3; - args[t].fail = &failures; - args[t].mu = μ - if (pthread_create(&th[t], NULL, probe_thread, &args[t]) == 0) started++; - } - for (int t = 0; t < started; t++) pthread_join(th[t], NULL); - CHECK(started == THREADS, "threads started (%d)", started); - CHECK(failures == 0, "concurrent probe failures=%d", failures); - pthread_mutex_destroy(&mu); -} - -/* ---- L4:行合成 O(1) + 截断提示行 ---- */ -static void test_rows(const char *root) +/* ---- L1 + L3:行合成零 IO / 副标题预生成 ---- */ +static void test_rows_zero_io(const char *root) { mr_library lib; memset(&lib, 0, sizeof lib); - mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "scan fixture"); + + for (int i = 0; i < lib.count; i++) { + mr_library_row row; + CHECK(mr_library_row_at(&lib, i, &row) == 1, "row %d", i); + CHECK(row.title && row.title[0], "row %d title", i); + CHECK(row.subtitle && row.subtitle[0] && strcmp(row.subtitle, "…") != 0, + "row %d subtitle pre-generated (%s)", i, row.subtitle ? row.subtitle : ""); + } mr_library_io_stats st; mr_library_test_io_reset(); - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); + long long t0 = now_us(); int rows = 0; for (int r = 0; r < 2000; r++) for (int i = 0; i < lib.count; i++) { mr_library_row row; if (mr_library_row_at(&lib, i, &row)) rows++; } - clock_gettime(CLOCK_MONOTONIC, &t1); + long long us = now_us() - t0; mr_library_test_io_get(&st); - long long us = (long long)(t1.tv_sec - t0.tv_sec) * 1000000LL + - (t1.tv_nsec - t0.tv_nsec) / 1000LL; CHECK(rows == 2000 * lib.count, "rows synthesized (%d)", rows); CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, "row synth is pure memory (open=%ld rd=%ld stat=%ld)", @@ -343,99 +133,93 @@ static void test_rows(const char *root) "truncation info row"); CHECK(strcmp(row.title, "已截断") == 0, "truncation title=%s", row.title); CHECK(strstr(row.subtitle, "2000") != NULL, "truncation subtitle=%s", row.subtitle); + mr_library_free(&lib); } -/* ---- L5:M7 兼容语义(直接含图兄弟册 + 自然序) ---- */ -static void collect_books(const char *parent, char names[][MR_NAME_MAX], int max, int *out_n) +/* ---- L2:书库图片下标 == 阅读器目录册下标 ---- */ +static void test_image_index_matches_reader(const char *dir) { mr_library lib; memset(&lib, 0, sizeof lib); - int n = 0; - if (mr_library_scan(&lib, parent, MR_LIB_MAX_DEPTH) > 0) { - for (int i = 0; i < lib.count && n < max; i++) { - mr_library_probe p; - if (mr_library_probe_cached(lib.items[i].path, &p) == 0 && p.pages > 0) { - snprintf(names[n], MR_NAME_MAX, "%s", lib.items[i].name); - n++; - } - } + CHECK(mr_library_scan(&lib, dir, MR_LIB_MAX_DEPTH) > 0, "scan mixed dir"); + + mr_dir_book db; + memset(&db, 0, sizeof db); + int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL); + CHECK(pages > 0, "reader dir book pages=%d", pages); + + /* 书库里的图片行按出现顺序,其下标必须依次是 0..n-1(无空洞/无重排)。 */ + int expect = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + int idx = mr_library_image_index(&lib, i); + CHECK(idx == expect, "image row %s index=%d (want %d)", + lib.items[i].name, idx, expect); + expect++; } + CHECK(expect == pages, "library images=%d vs reader pages=%d", expect, pages); + if (expect != pages) { + mr_library_free(&lib); + mr_dir_book_free(&db); + return; + } + + /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(下标即起始页)。 */ + int k = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + char page_path[MR_PATH_MAX]; + mr_dir_book_path(&db, k, page_path, (int)sizeof page_path); + CHECK(strcmp(page_path, lib.items[i].path) == 0, + "page[%d]: library=%s reader=%s", k, lib.items[i].path, page_path); + k++; + } + + /* 档案册/目录不占图片下标。 */ + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue; + CHECK(mr_library_image_index(&lib, i) == -1, + "non-image %s has no image index", lib.items[i].name); + } + + mr_dir_book_free(&db); mr_library_free(&lib); - *out_n = n; -} - -static int book_index(char names[][MR_NAME_MAX], int n, const char *cur) -{ - for (int i = 0; i < n; i++) - if (strcmp(names[i], cur) == 0) return i; - return -1; -} - -static void test_m7_semantics(const char *root) -{ - char parent[MR_PATH_MAX]; - joinp(parent, sizeof parent, root, "series"); - - char names[16][MR_NAME_MAX]; - int n = 0; - collect_books(parent, names, 16, &n); - CHECK(n == 3, "series books=%d (vol1/vol2/vol10; vol6 is not a book)", n); - if (n == 3) { - CHECK(strcmp(names[0], "vol1") == 0, "book[0]=%s", names[0]); - CHECK(strcmp(names[1], "vol2") == 0, "book[1]=%s", names[1]); - CHECK(strcmp(names[2], "vol10") == 0, "book[2]=%s", names[2]); - } - CHECK(book_index(names, n, "vol6") < 0, "dirs-only vol6 skipped (direct images only)"); - CHECK(book_index(names, n, ".hidden") < 0, "hidden skipped"); - CHECK(book_index(names, n, "MeowOS") < 0, "system dir without direct images skipped"); - - int i2 = book_index(names, n, "vol2"); - CHECK(i2 >= 0 && i2 + 1 < n && strcmp(names[i2 + 1], "vol10") == 0, - "vol2 next = vol10 (natural order, vol6 skipped)"); - CHECK(book_index(names, n, "vol10") == n - 1, "vol10 is last (no next)"); - CHECK(book_index(names, n, "vol1") == 0, "vol1 is first (no prev)"); } int main(void) { - char tmpl[] = "./mr_lazy_XXXXXX"; + char tmpl[] = "./mr_rows_XXXXXX"; char *root = mkdtemp(tmpl); CHECK(root != NULL, "mkdtemp"); if (!root) return 1; char p[MR_PATH_MAX]; - const char *dirs[] = { "book", "noimg", "empty", "dirs_only/deep", "series" }; + const char *dirs[] = { "book", "mixed", "mixed/sub", "empty" }; for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { joinp(p, sizeof p, root, dirs[i]); CHECK(mkdir_p(p) == 0, "mkdir %s", p); } - joinp(p, sizeof p, root, "book/page2.png"); CHECK(touch_file(p) == 0, "page2"); joinp(p, sizeof p, root, "book/page10.jpg"); CHECK(touch_file(p) == 0, "page10"); joinp(p, sizeof p, root, "book/notes.txt"); CHECK(touch_file(p) == 0, "notes"); - joinp(p, sizeof p, root, "noimg/readme.txt"); CHECK(touch_file(p) == 0, "readme"); - joinp(p, sizeof p, root, "dirs_only/deep/a.png"); CHECK(touch_file(p) == 0, "deep a"); + joinp(p, sizeof p, root, "mixed/sub/zz.png");CHECK(touch_file(p) == 0, "nested"); - /* M7 语义夹具:vol1/vol2/vol10 直接含图;vol6 只有子目录含图;.hidden / MeowOS 干扰项 */ - const char *mk[] = { "series/vol1/001.png", "series/vol2/001.png", - "series/vol10/001.png", "series/vol6/sub/001.png", - "series/.hidden/001.png", "series/MeowOS/deep/001.png" }; - for (unsigned i = 0; i < sizeof mk / sizeof mk[0]; i++) { - joinp(p, sizeof p, root, mk[i]); - /* 先建父目录,再落文件 */ - char dir[MR_PATH_MAX]; - snprintf(dir, sizeof dir, "%s", p); - char *slash = strrchr(dir, '/'); - if (slash) { *slash = '\0'; CHECK(mkdir_p(dir) == 0, "mkdir %s", dir); } + /* 大小写混排 + 数字序号 + 大写扩展名 + 前导零 + 夹档案/普通文件。 */ + const char *mixed[] = { + "A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png", + "page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt", ".hidden.png" + }; + for (unsigned i = 0; i < sizeof mixed / sizeof mixed[0]; i++) { + joinp(p, sizeof p, root, "mixed"); + size_t dl = strlen(p); + snprintf(p + dl, sizeof p - dl, "/%s", mixed[i]); CHECK(touch_file(p) == 0, "image %s", p); } - test_probe_and_apply(root); - test_cache(root); - test_cache_race(root); - test_rows(root); - test_m7_semantics(root); + test_rows_zero_io(root); + joinp(p, sizeof p, root, "mixed"); + test_image_index_matches_reader(p); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_browse_nav.c b/tuku/test/test_browse_nav.c index 58240a6..93d8221 100644 --- a/tuku/test/test_browse_nav.c +++ b/tuku/test/test_browse_nav.c @@ -1,17 +1,16 @@ -/* test_browse_nav.c — 浏览访问放开 / 上溯父目录 / 截断标注(纯 C,无 SDK)。 +/* test_browse_nav.c — 浏览模型 / 进入目录 / 截断(纯 C,无 SDK)。 * - * 覆盖(M13 用户要求②): - * N1 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, - * 空目录/无图目录都列出且可进入(scan 返回 0 而不是 -1); - * N2 隐藏项/普通文件/符号链接仍不列(防环、防噪声); - * N3 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此提示并停留; - * N4 上溯父目录:mr_path_parent 可一路走到文件系统 "/"(不再以 TF 卡根为界); - * N5 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。 + * 覆盖(M18 文件管理器模型): + * N1 单层列表:目录 + 受支持图片 + 档案册(cbz/zip)都成行,目录优先+自然序; + * 系统目录/空目录/无图目录都列出且可进入;隐藏项/普通文件/符号链接不列; + * N2 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此显示空态或提示; + * N3 父目录路径推导:mr_path_parent 是纯路径工具(可算到文件系统 "/"; + * 浏览越界的限制在 UI 导航层,见 test_browse_ui_host 的根锁定用例); + * N4 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ - * test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \ - * -pthread -o build/host/test_browse_nav + * test/test_browse_nav.c logic/library.c -pthread -o build/host/test_browse_nav */ #include "logic/library.h" @@ -85,19 +84,20 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } -/* ---- N1/N2 ---- */ +/* ---- N1 ---- */ static void test_open_access(const char *root) { mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); - CHECK(n == 7, "root lists 7 dirs (got %d)", n); + CHECK(n == 9, "root lists 9 rows (got %d)", n); CHECK(lib.truncated == 0, "root not truncated"); - static const char *want[7] = { - "book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg" + static const char *want[9] = { + "book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg", + "album.cbz", "loose.png" }; - for (int i = 0; i < 7 && i < lib.count; i++) + for (int i = 0; i < 9 && i < lib.count; i++) CHECK(strcmp(lib.items[i].name, want[i]) == 0, "sort[%d]=%s got %s", i, want[i], lib.items[i].name); @@ -105,11 +105,13 @@ static void test_open_access(const char *root) CHECK(find_entry(&lib, want[i]) != NULL, "system/empty/noimg dir listed: %s", want[i]); CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); CHECK(find_entry(&lib, "linkdir") == NULL, "symlink dir excluded"); - CHECK(find_entry(&lib, "loose.png") == NULL, "file excluded"); + CHECK(find_entry(&lib, "loose.png") != NULL, "loose image listed (clickable)"); + CHECK(find_entry(&lib, "album.cbz") != NULL, "archive listed"); + CHECK(find_entry(&lib, "readme.txt") == NULL, "plain file excluded"); /* 可进入:每个列出的目录都能被 scan(0 项也算成功,不等于 -1)。 */ - char p[MR_PATH_MAX]; for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_DIR) continue; mr_library child; memset(&child, 0, sizeof child); CHECK(mr_library_scan(&child, lib.items[i].path, MR_LIB_MAX_DEPTH) >= 0, @@ -117,11 +119,20 @@ static void test_open_access(const char *root) mr_library_free(&child); } + char p[MR_PATH_MAX]; + joinp(p, sizeof p, root, "book"); + mr_library book; + memset(&book, 0, sizeof book); + CHECK(mr_library_scan(&book, p, MR_LIB_MAX_DEPTH) == 1 && + find_entry(&book, "001.png") != NULL, "dir with images lists the image"); + mr_library_free(&book); + joinp(p, sizeof p, root, "MeowOS"); mr_library syslib; memset(&syslib, 0, sizeof syslib); - CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 1 && - find_entry(&syslib, "sub") != NULL, "system dir browsable"); + CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 2 && + find_entry(&syslib, "sub") != NULL && + find_entry(&syslib, "001.png") != NULL, "system dir browsable"); mr_library_free(&syslib); joinp(p, sizeof p, root, "empty"); @@ -133,13 +144,13 @@ static void test_open_access(const char *root) mr_library_free(&lib); } -/* ---- N3 ---- */ +/* ---- N2 ---- */ static void test_unreadable(const char *root) { mr_library lib; char p[MR_PATH_MAX]; memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, "/no/such/dir/m13", MR_LIB_MAX_DEPTH) == -1, "missing -> -1"); + CHECK(mr_library_scan(&lib, "/no/such/dir/m18", MR_LIB_MAX_DEPTH) == -1, "missing -> -1"); CHECK(lib.count == 0 && lib.truncated == 0, "failed scan leaves empty lib"); mr_library_free(&lib); @@ -160,8 +171,8 @@ static void test_unreadable(const char *root) } } -/* ---- N4 ---- */ -static void test_up_to_root(void) +/* ---- N3 ---- */ +static void test_parent_path(void) { char cur[MR_PATH_MAX]; snprintf(cur, sizeof cur, "/usr/data/mnt/sd_0/a/b/c"); @@ -183,10 +194,10 @@ static void test_up_to_root(void) "root parent stays /"); CHECK(mr_path_parent("/usr/data/mnt/sd_0", up, (int)sizeof up) == 0 && strcmp(up, "/usr/data/mnt") == 0, - "TF root can go above mount point: %s", up); + "path helper is root-agnostic: %s", up); } -/* ---- N5 ---- */ +/* ---- N4 ---- */ static void test_truncation(void) { CHECK(MR_LIB_SCAN_MAX == 2000, "default cap is 2000 (got %d)", MR_LIB_SCAN_MAX); @@ -241,6 +252,10 @@ int main(void) touch_file(p); joinp(p, sizeof p, root, "loose.png"); touch_file(p); + joinp(p, sizeof p, root, "album.cbz"); + touch_file(p); + joinp(p, sizeof p, root, "readme.txt"); + touch_file(p); /* 符号链接目录不参与浏览(有权限才建;建不了不影响其余断言)。 */ char target[MR_PATH_MAX], link[MR_PATH_MAX]; joinp(target, sizeof target, root, "book"); @@ -249,7 +264,7 @@ int main(void) test_open_access(root); test_unreadable(root); - test_up_to_root(); + test_parent_path(); rm_rf(root); test_truncation(); diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c index 596f9d4..6c52e6b 100644 --- a/tuku/test/test_browse_perf.c +++ b/tuku/test/test_browse_perf.c @@ -1,17 +1,16 @@ -/* test_browse_perf.c — 单层扫描性能/有界性验收(纯 C,无 SDK)。 +/* test_browse_perf.c — 单层扫描 / 行合成性能与有界性验收(纯 C,无 SDK)。 * - * 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个文件。 - * 断言(对应 M13 任务⑤): - * P1 首帧扫描只做单层:opendir 次数 == 1(旧实现会递归打开数千个目录); - * lstat 次数 == 本层条目数;耗时有界; - * P2 后台探测同样只读一层:每个目录一次 opendir,不进入其嵌套子树; - * P3 行合成(UI src/bind 的数据面)纯内存、零 IO、O(1); - * P4 深目录直接扫描也只打开它自己。 + * 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个普通文件 + * + 一个 big/ 目录(2000 张图片,验证 2000 项大目录)。 + * 断言(对应 M18 任务③): + * P1 首帧扫描只做单层:opendir 次数 == 1,lstat 次数 == 本层条目数,耗时有界; + * P2 2000 项大目录:一次扫描出全部 2000 行,opendir=1、lstat=2000,耗时有界; + * P3 行合成(UI src 的数据面)纯内存、零 IO、耗时有界(2000 行 × 1000 遍); + * P4 进入深层/普通子目录同样只打开它自己(进入目录成本 = 该目录一层)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ - * test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \ - * -pthread -o build/host/test_browse_perf + * test/test_browse_perf.c logic/library.c -pthread -o build/host/test_browse_perf */ #include "logic/library.h" @@ -26,6 +25,7 @@ #define PERF_DIRS 500 #define PERF_DEPTH 8 #define PERF_FILES 2000 +#define PERF_BIG 2000 static int g_pass, g_fail; #define CHECK(cond, ...) do { \ @@ -92,7 +92,7 @@ int main(void) if (!root) return 1; char p[MR_PATH_MAX]; - /* 根目录:2000 个文件 + 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */ + /* 根目录:2000 个普通文件(不成行)+ 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */ for (int f = 0; f < PERF_FILES; f++) { snprintf(p, sizeof p, "%s/f%04d.txt", root, f); if (touch_file(p) != 0) { CHECK(0, "file %s", p); break; } @@ -101,7 +101,7 @@ int main(void) char deep[MR_PATH_MAX]; snprintf(deep, sizeof deep, "%s/d%04d", root, d); if (mkdir_p(deep) != 0) { CHECK(0, "mkdir %s", deep); break; } - snprintf(p, sizeof p, "%s/001.png", deep); /* 顶层目录自身直接含图(=一本书) */ + snprintf(p, sizeof p, "%s/001.png", deep); if (touch_file(p) != 0) { CHECK(0, "touched top image"); break; } for (int l = 1; l <= PERF_DEPTH; l++) { char sub[MR_PATH_MAX]; @@ -113,6 +113,15 @@ int main(void) if (touch_file(p) != 0) { CHECK(0, "touched deep image"); break; } } + /* 2000 项大目录(图片)。 */ + char big[MR_PATH_MAX]; + snprintf(big, sizeof big, "%s/big", root); + CHECK(mkdir_p(big) == 0, "mkdir big"); + for (int i = 0; i < PERF_BIG; i++) { + snprintf(p, sizeof p, "%s/img%04d.png", big, i); + if (touch_file(p) != 0) { CHECK(0, "big image %s", p); break; } + } + /* ---- P1:首帧扫描只做单层 ---- */ mr_library lib; memset(&lib, 0, sizeof lib); @@ -123,61 +132,67 @@ int main(void) mr_library_io_stats st; mr_library_test_io_get(&st); - CHECK(n == PERF_DIRS, "scan lists %d dirs (got %d)", PERF_DIRS, n); + CHECK(n == PERF_DIRS + 1, "scan lists %d dirs (got %d)", PERF_DIRS + 1, n); CHECK(lib.truncated == 0, "not truncated"); CHECK(st.opendirs == 1, "single-layer scan: opendir=%ld (want 1)", st.opendirs); - CHECK(st.lstats == PERF_FILES + PERF_DIRS, - "lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS); + CHECK(st.lstats == PERF_FILES + PERF_DIRS + 1, + "lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS + 1); CHECK(scan_us < 1500000LL, "scan bounded: %lldus", scan_us); - if (lib.count > 1) { - CHECK(strcmp(lib.items[0].name, "d0000") == 0, "first=%s", lib.items[0].name); + if (lib.count == PERF_DIRS + 1) { + CHECK(strcmp(lib.items[0].name, "big") == 0, "first=%s", lib.items[0].name); CHECK(strcmp(lib.items[lib.count - 1].name, "d0499") == 0, "last=%s", lib.items[lib.count - 1].name); } - printf(" scan: %lldus, %d dirs, opendir=%ld lstat=%ld\n", + printf(" root scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", scan_us, n, st.opendirs, st.lstats); + mr_library_free(&lib); - /* ---- P2:后台探测每目录只开一次,不进入嵌套子树 ---- */ + /* ---- P2:2000 项大目录(进入目录 = 单层扫描) ---- */ + mr_library biglib; + memset(&biglib, 0, sizeof biglib); mr_library_test_io_reset(); - long long p0 = now_us(); - int bad = 0; - for (int i = 0; i < lib.count; i++) { - mr_library_probe probe; - if (mr_library_probe_cached(lib.items[i].path, &probe) != 0 || probe.pages != 1) - bad++; - } - long long probe_us = now_us() - p0; + long long b0 = now_us(); + int bn = mr_library_scan(&biglib, big, MR_LIB_MAX_DEPTH); + long long big_us = now_us() - b0; mr_library_test_io_get(&st); - CHECK(bad == 0, "all top dirs have 1 direct image (bad=%d)", bad); - CHECK(st.opendirs == PERF_DIRS, - "probe stays shallow: opendir=%ld (want %d, not %d)", - st.opendirs, PERF_DIRS, PERF_DIRS * (PERF_DEPTH + 1)); - CHECK(st.lstats == PERF_DIRS, - "probe lstats only image candidates: %ld (want %d)", st.lstats, PERF_DIRS); - CHECK(probe_us < 5000000LL, "probe bounded: %lldus", probe_us); - printf(" probe: %lldus, opendir=%ld lstat=%ld\n", probe_us, st.opendirs, st.lstats); + CHECK(bn == PERF_BIG, "big dir rows=%d (want %d)", bn, PERF_BIG); + CHECK(biglib.truncated == 0, "big dir not truncated at cap"); + CHECK(st.opendirs == 1, "big dir scan: opendir=%ld", st.opendirs); + CHECK(st.lstats == PERF_BIG, "big dir scan: lstat=%ld (want %d)", st.lstats, PERF_BIG); + CHECK(big_us < 1500000LL, "big dir scan bounded: %lldus", big_us); + if (biglib.count == PERF_BIG) { + CHECK(strcmp(biglib.items[0].name, "img0000.png") == 0, "first=%s", biglib.items[0].name); + CHECK(strcmp(biglib.items[PERF_BIG - 1].name, "img1999.png") == 0, + "last=%s", biglib.items[PERF_BIG - 1].name); + CHECK(biglib.items[PERF_BIG - 1].kind == MR_LIB_KIND_IMAGE, "last is image"); + CHECK(mr_library_image_index(&biglib, PERF_BIG - 1) == PERF_BIG - 1, + "image index at end"); + } + printf(" big dir scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", + big_us, bn, st.opendirs, st.lstats); - /* ---- P3:行合成(bind/src 数据面)纯内存 O(1) ---- */ + /* ---- P3:行合成(UI src 数据面)纯内存、2000 行 × 1000 遍 ---- */ mr_library_test_io_reset(); long long r0 = now_us(); int rows = 0; for (int r = 0; r < 1000; r++) - for (int i = 0; i < lib.count; i++) { + for (int i = 0; i < biglib.count; i++) { mr_library_row row; - if (mr_library_row_at(&lib, i, &row)) rows++; + if (mr_library_row_at(&biglib, i, &row)) rows++; } long long row_us = now_us() - r0; mr_library_test_io_get(&st); - CHECK(rows == 1000 * lib.count, "rows synthesized (%d)", rows); + CHECK(rows == 1000 * biglib.count, "rows synthesized (%d)", rows); CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, "row synthesis does no IO (open=%ld rd=%ld stat=%ld)", st.opendirs, st.readdirs, st.lstats); CHECK(row_us < 1000000LL, "row synthesis bounded: %lldus", row_us); printf(" rows: %lldus for %d rows, zero IO\n", row_us, rows); + mr_library_free(&biglib); - /* ---- P4:直接扫描深层目录同样只打开它自己 ---- */ + /* ---- P4:进入子目录只打开它自己(不递归子树) ---- */ char deep[MR_PATH_MAX]; - snprintf(deep, sizeof deep, "%s", lib.items[0].path); + snprintf(deep, sizeof deep, "%s/d0000", root); for (int l = 1; l <= PERF_DEPTH; l++) { char sub[MR_PATH_MAX]; snprintf(sub, sizeof sub, "%s/l%d", deep, l); @@ -186,13 +201,22 @@ int main(void) mr_library_test_io_reset(); mr_library deep_lib; memset(&deep_lib, 0, sizeof deep_lib); - CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 0, "deep leaf has no subdir"); + CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 1, "deep leaf has one image"); mr_library_test_io_get(&st); CHECK(st.opendirs == 1, "deep scan opens once (got %ld)", st.opendirs); CHECK(st.lstats == 1, "deep scan lstats only its image (got %ld)", st.lstats); mr_library_free(&deep_lib); - mr_library_free(&lib); + /* 顶层目录:一个子目录 + 一张图 = 2 行,只打开它自己。 */ + snprintf(deep, sizeof deep, "%s/d0000", root); + memset(&deep_lib, 0, sizeof deep_lib); + mr_library_test_io_reset(); + CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 2, "top dir rows"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1 && st.lstats == 2, "top dir scan: open=%ld stat=%ld", + st.opendirs, st.lstats); + mr_library_free(&deep_lib); + rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); return g_fail ? 1 : 0; diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index 6ac52f6..57f64e4 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -1,33 +1,35 @@ /* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。 * - * 目的(M13): - * U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、 - * on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到); - * U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」; - * U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出); - * 「TF卡」回根动作回到书库根; - * U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅; - * U5 启动根不可读:create 时仍建空列表(不是只有横幅); - * U6 缩略图 LRU 跳过解压页缓存(M11 P2-1 回归):cache_prune 只统计/删除缩略图, - * mrpage_*(page_cache 的解压页)必须保留且不计入 64 张额度。 + * 目的(M18 文件管理器模型): + * U1 行模型:目录 + 图片 + 档案册混排(目录优先+自然序),行副标题是扫描期生成的 + * 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分; + * U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问); + * U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读 + * (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册; + * U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出); + * 子目录返回=回根;不存在越过浏览根的导航; + * U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」, + * 不弹错误横幅;顶栏刷新/重扫可重试成功; + * U6 子目录不可读:停留原目录 + 顶部提示横幅; + * U7 书库不再碰缓存目录:CBZ 解压页缓存 mrpage_* 原样保留,且不写任何 bmp。 * - * 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩; - * 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以 - * -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;本测试另打桩 mr_reader_open_dir/mr_nav_report。 + * 编译:见 test/run_tests.sh(链 SDK 源码树 + 打桩;只发布 meow_ui.a 的环境自动 SKIP)。 + * ui/library/library.c 以 -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;logic/library.c + * 以 -DMR_LIBRARY_TEST_HOOKS 编译出 IO 计数。 */ #include "tuku_internal.h" #include "logic/library.h" +#include "logic/reader/page_cache.h" #include #include #include #include #include -#include #include #include -/* ---- ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) ---- */ +/* ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) */ int library_test_load(const char *dir); void library_test_set_root(const char *root); const char *library_test_root(void); @@ -37,14 +39,26 @@ int library_test_rows(void); int library_test_src(int idx, meow_list_item *item); int library_test_hint_active(void); const char *library_test_hint(void); -int library_test_go_root(void); +int library_test_topbar_back(void); +const char *library_test_empty_text(void); +int library_test_sd_missing(void); +int library_test_retry(void); int library_test_go_up(void); -int library_test_go_parent(void); int library_test_drag_active(void); int library_test_has_list(void); -const char *library_test_cache_dir(void); -int library_test_cache_on(void); -void library_test_cache_prune(void); + +/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */ +#define MR_READER_START_RESUME (-1) +void mr_reader_open_dir_at(const char *dir, int start_index); +static char g_opened[MR_PATH_MAX]; +static int g_opened_start; +static int g_opened_n; +void mr_reader_open_dir_at(const char *dir, int start_index) +{ + snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); + g_opened_start = start_index; + g_opened_n++; +} static int g_fail; #define CHECK(cond, ...) do { \ @@ -55,15 +69,6 @@ static int g_fail; /* app.c 的导航上报在测试里不需要网络侧实现。 */ void mr_nav_report(void) {} -/* reader 打桩:只记录被打开的册路径(本测试不建 reader 页)。 */ -static char g_opened[MR_PATH_MAX]; -static int g_opened_n; -void mr_reader_open_dir(const char *path) -{ - snprintf(g_opened, sizeof g_opened, "%s", path ? path : ""); - g_opened_n++; -} - static int mkdir_p(const char *path) { char tmp[MR_PATH_MAX]; @@ -125,16 +130,15 @@ static int row_index_for(const char *name) return -1; } -static void set_mtime(const char *path, time_t t) +static void tap_row(const char *name) { - struct timespec ts[2]; - ts[0].tv_sec = t; - ts[0].tv_nsec = 0; - ts[1] = ts[0]; - utimensat(AT_FDCWD, path, ts, 0); + int idx = row_index_for(name); + if (idx < 0) { CHECK(0, "row %s not found", name); return; } + int list_top = meow_ui_statusbar_height() + 56; + library_on_tap(120, list_top + idx * 56 + 28); } -/* 统计缓存目录里的缩略图(*.bmp 且非 mrpage_*)与解压页(mrpage_*.bmp)。 */ +/* 统计缓存目录里的 bmp(缩略图/解压页)。 */ static int count_bmp(const char *dir, int *pages) { DIR *d = opendir(dir); @@ -144,7 +148,7 @@ static int count_bmp(const char *dir, int *pages) while ((de = readdir(d)) != NULL) { size_t len = strlen(de->d_name); if (len <= 4 || strcmp(de->d_name + len - 4, ".bmp") != 0) continue; - if (strncmp(de->d_name, "mrpage_", 7) == 0) pg++; + if (mr_cache_is_page_name(de->d_name)) pg++; else thumbs++; } closedir(d); @@ -152,71 +156,6 @@ static int count_bmp(const char *dir, int *pages) return thumbs; } -static void wait_ready(int ms) -{ - for (int t = 0; t < ms / 10; t++) { - library_on_tick(); - int ready = 1; - int rows = library_test_rows(); - for (int i = 0; i < rows; i++) { - meow_list_item it; - if (!library_test_src(i, &it)) continue; - if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; } - } - if (ready) return; - usleep(10000); - } -} - -/* U6:缩略图 LRU 必须跳过解压页缓存(M11 P2-1 回归;MR_CACHE_MAX_FILES=64)。 */ -static void test_cache_prune_skips_pages(void) -{ - const char *dir = library_test_cache_dir(); - CHECK(library_test_cache_on() && dir && dir[0], "thumbnail cache enabled: %s", - dir ? dir : ""); - if (!dir || !dir[0]) return; - - /* 清空之前可能写入的 bmp(本测试独占 MEOW_DATA_DIR)。 */ - DIR *d = opendir(dir); - if (d) { - struct dirent *de; - while ((de = readdir(d)) != NULL) { - size_t len = strlen(de->d_name); - if (len > 4 && strcmp(de->d_name + len - 4, ".bmp") == 0) { - char full[MR_PATH_MAX + 96]; - snprintf(full, sizeof full, "%s/%s", dir, de->d_name); - unlink(full); - } - } - closedir(d); - } - - char page[MR_PATH_MAX + 96], thumb[MR_PATH_MAX + 96]; - snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", dir); - CHECK(touch_file(page) == 0, "create page cache file"); - set_mtime(page, time(NULL) - 7200); /* 最旧:若被误删一定先删它 */ - - time_t base = time(NULL) - 3600; - for (int i = 0; i < 65; i++) { /* 65 张缩略图 > MR_CACHE_MAX_FILES */ - snprintf(thumb, sizeof thumb, "%s/%016x_%ld_%d.bmp", dir, - (unsigned)i, (long)(base + i), i); - CHECK(touch_file(thumb) == 0, "create thumbnail %d", i); - set_mtime(thumb, base + i); - } - - int pages_before = 0; - CHECK(count_bmp(dir, &pages_before) == 65, "precondition 65 thumbnails"); - CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before); - - library_test_cache_prune(); - - int pages_after = 0; - int thumbs_after = count_bmp(dir, &pages_after); - CHECK(access(page, R_OK) == 0, "mrpage_* survives thumbnail LRU"); - CHECK(pages_after == 1, "mrpage_* not counted as thumbnail (got %d)", pages_after); - CHECK(thumbs_after == 64, "thumbnails trimmed to 64 (got %d)", thumbs_after); -} - int main(void) { const char *base = getenv("TMPDIR"); @@ -227,129 +166,192 @@ int main(void) if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } setenv("MEOW_DATA_DIR", data, 1); - char root[MR_PATH_MAX]; + char root[MR_PATH_MAX], p[MR_PATH_MAX]; snprintf(root, sizeof root, "%s/library", data); - char p[MR_PATH_MAX]; - const char *dirs[] = { "book", "empty", "noimg", "deep/sub", "MeowOS/sub" }; + const char *dirs[] = { "album", "empty", "noimg", "deep/sub", "Pictures", "Pictures/sub" }; for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { joinp(p, sizeof p, root, dirs[i]); if (mkdir_p(p) != 0) { printf("SKIP: mkdir %s failed\n", p); return 0; } } - joinp(p, sizeof p, root, "book/page2.png"); touch_file(p); - joinp(p, sizeof p, root, "book/page10.jpg"); touch_file(p); + joinp(p, sizeof p, root, "album/001.png"); touch_file(p); + joinp(p, sizeof p, root, "album/002.png"); touch_file(p); joinp(p, sizeof p, root, "noimg/readme.txt"); touch_file(p); joinp(p, sizeof p, root, "deep/sub/001.png"); touch_file(p); - joinp(p, sizeof p, root, "MeowOS/sub/m.png"); touch_file(p); + joinp(p, sizeof p, root, "root.png"); touch_file(p); + joinp(p, sizeof p, root, "book.cbz"); touch_file(p); + + /* Pictures:大小写混排 + 数字序号 + 大写扩展名 + 档案册(图片排序契约夹具)。 */ + const char *pics[] = { "A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png", + "page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt" }; + for (unsigned i = 0; i < sizeof pics / sizeof pics[0]; i++) { + joinp(p, sizeof p, root, "Pictures"); + size_t dl = strlen(p); + snprintf(p + dl, sizeof p - dl, "/%s", pics[i]); + touch_file(p); + } meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } - /* U5:先指向一个不存在的根,再 create —— 真实走「启动时根目录不可读」分支。 */ - const char *bad_root = "/no/such/tuku/m13_root"; + /* ---- U5:浏览根不可访问 —— 先指向不存在的根,再 create ---- */ + const char *bad_root = "/no/such/tuku/m18_root"; library_test_set_root(bad_root); CHECK(strcmp(library_test_root(), bad_root) == 0, "bad root installed"); meow_view *lib = library_view_create(); - CHECK(library_test_hint_active(), "startup unreadable root shows hint: %s", - library_test_hint()); - CHECK(library_test_rows() == 0, "startup empty list rows=%d", library_test_rows()); - CHECK(library_test_has_list(), "startup built listview for empty list"); + CHECK(library_test_sd_missing() == 1, "unreadable root -> SD missing state"); + CHECK(library_test_rows() == 0, "empty list rows=%d", library_test_rows()); + CHECK(library_test_has_list(), "startup still builds a listview"); CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir()); + CHECK(strstr(library_test_empty_text(), "SD") != NULL, + "friendly empty state: %s", library_test_empty_text()); + CHECK(library_test_hint_active() == 0, "no error banner for missing SD"); + CHECK(library_test_topbar_back() == 0, "no back arrow at storage root"); + CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1, + "retry while still missing stays in empty state (no crash)"); + /* 根路径不是目录(普通文件)同样走 SD 空态。 */ + char notadir[MR_PATH_MAX]; + joinp(notadir, sizeof notadir, data, "notadir"); + touch_file(notadir); + library_test_set_root(notadir); + library_test_set_dir(notadir); + CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1, + "root pointing at a file -> SD missing state"); + + /* 插卡/挂载恢复:同一路径变成可读目录后,刷新即恢复列表。 */ library_test_set_root(root); - CHECK(strcmp(library_test_root(), root) == 0, "test root installed"); + library_test_set_dir(root); + CHECK(library_test_retry() > 0, "retry after storage available loads rows"); + CHECK(library_test_sd_missing() == 0, "SD missing state cleared"); + CHECK(strcmp(library_test_dir(), root) == 0, "now browsing root"); + CHECK(library_test_topbar_back() == 0, "still no back arrow at root"); + meow_view_page_push(ACT_LIBRARY); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active"); - /* ---- U1:src2 行 + 懒算补位 ---- */ - CHECK(library_test_load(root) == 1, "load fixture"); - CHECK(library_test_rows() == 5, "5 rows (got %d)", library_test_rows()); - - static const char *want[5] = { "book", "deep", "empty", "MeowOS", "noimg" }; - for (int i = 0; i < 5; i++) { + /* ---- U1:行模型(目录优先 + 自然序 + 类型图标/箭头) ---- */ + CHECK(library_test_load(root) == 1, "load fixture root"); + CHECK(library_test_rows() == 7, "7 rows (got %d)", library_test_rows()); + static const char *want[7] = { + "album", "deep", "empty", "noimg", "Pictures", "book.cbz", "root.png" + }; + for (int i = 0; i < 7; i++) { meow_list_item it; CHECK(library_test_src(i, &it) == 1, "src row %d", i); CHECK(it.title && strcmp(it.title, want[i]) == 0, "row[%d]=%s (want %s)", i, it.title ? it.title : "(null)", want[i]); - CHECK(it.subtitle && strcmp(it.subtitle, "…") == 0, - "row[%d] starts with placeholder (got %s)", i, it.subtitle ? it.subtitle : ""); - CHECK(it.right == MEOW_LIST_RIGHT_ARROW, "row[%d] has enter arrow while pending", i); + CHECK(it.subtitle && it.subtitle[0] && strcmp(it.subtitle, "…") != 0, + "row[%d] subtitle pre-generated (%s)", i, it.subtitle ? it.subtitle : ""); + if (i < 5) { + CHECK(it.icon_kind == MEOW_LIST_ICON_FOLDER && it.right == MEOW_LIST_RIGHT_ARROW, + "row[%d] is a folder row with enter arrow", i); + } else { + CHECK(it.icon_kind == MEOW_LIST_ICON_FILE && it.right == MEOW_LIST_RIGHT_NONE, + "row[%d] is a file row without child arrow", i); + } } - - wait_ready(3000); - /* U2:补位后的行语义 */ - for (int i = 0; i < 5; i++) { - meow_list_item it; - library_test_src(i, &it); - CHECK(it.subtitle && strcmp(it.subtitle, "…") != 0, - "row[%d] subtitle filled (got %s)", i, it.subtitle ? it.subtitle : ""); - } - int ib = row_index_for("book"); - int ie = row_index_for("empty"); - int in = row_index_for("noimg"); - int id = row_index_for("deep"); + int ic = row_index_for("book.cbz"); meow_list_item it; - CHECK(ib >= 0 && library_test_src(ib, &it) && strcmp(it.subtitle, "2 pages") == 0, - "book subtitle=%s", it.subtitle ? it.subtitle : ""); - CHECK(it.right == MEOW_LIST_RIGHT_NONE, "book row has no enter arrow (opens reader)"); - CHECK(ie >= 0 && library_test_src(ie, &it) && strcmp(it.subtitle, "空目录") == 0, - "empty subtitle=%s", it.subtitle ? it.subtitle : ""); - CHECK(in >= 0 && library_test_src(in, &it) && strcmp(it.subtitle, "无图片") == 0, - "noimg subtitle=%s", it.subtitle ? it.subtitle : ""); - CHECK(id >= 0 && library_test_src(id, &it) && strcmp(it.subtitle, "无图片") == 0, - "deep (images only in subdir) is not a book: %s", it.subtitle ? it.subtitle : ""); + CHECK(ic >= 0 && library_test_src(ic, &it) && + strncmp(it.subtitle, "档案册 · ", strlen("档案册 · ")) == 0, + "archive subtitle=%s", it.subtitle ? it.subtitle : ""); + int ir = row_index_for("root.png"); + CHECK(ir >= 0 && library_test_src(ir, &it) && + strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0, + "image subtitle=%s", it.subtitle ? it.subtitle : ""); - /* ---- 行点击:书 → reader;空目录 → 进入 ---- */ - int list_top = meow_ui_statusbar_height() + 56; + /* ---- U2:行合成零 IO(渲染/滚动路径不访问文件系统) ---- */ + mr_library_test_io_reset(); + for (int pass = 0; pass < 500; pass++) + for (int i = 0; i < library_test_rows(); i++) { + meow_list_item tmp; + library_test_src(i, &tmp); + } + mr_library_io_stats io; + mr_library_test_io_get(&io); + CHECK(io.opendirs == 0 && io.readdirs == 0 && io.lstats == 0, + "row source does no IO (open=%ld rd=%ld stat=%ld)", + io.opendirs, io.readdirs, io.lstats); + + /* ---- U3:点击语义 ---- */ + char pics_dir[MR_PATH_MAX]; + joinp(pics_dir, sizeof pics_dir, root, "Pictures"); + CHECK(library_test_load(pics_dir) == 1, "entered Pictures"); + CHECK(strcmp(library_test_dir(), pics_dir) == 0, "current dir=%s", library_test_dir()); + + /* 图片下标 = 该目录里排在该图之前的图片行数(阅读器目录册同序)。 */ + const char *target = "img10.png"; + int ti = row_index_for(target); + CHECK(ti >= 0, "target image row present"); + int expect_idx = 0; + for (int i = 0; i < ti; i++) { + meow_list_item tmp; + if (!library_test_src(i, &tmp)) continue; + if (tmp.subtitle && strncmp(tmp.subtitle, "图片 · ", strlen("图片 · ")) == 0) + expect_idx++; + } g_opened_n = 0; - library_on_tap(120, list_top + ib * 56 + 28); - CHECK(g_opened_n == 1 && strstr(g_opened, "/book") != NULL, - "tap book opens reader (%s)", g_opened); - CHECK(strcmp(library_test_dir(), root) == 0, "tap book does not navigate"); + tap_row(target); + CHECK(g_opened_n == 1 && strcmp(g_opened, pics_dir) == 0, + "tap image opens reader for current dir (%s)", g_opened); + CHECK(g_opened_start == expect_idx, "start index=%d (want %d)", g_opened_start, expect_idx); + CHECK(strcmp(library_test_dir(), pics_dir) == 0, "tap image does not navigate"); - library_on_tap(120, list_top + ie * 56 + 28); - char empty_path[MR_PATH_MAX]; - joinp(empty_path, sizeof empty_path, root, "empty"); - CHECK(strcmp(library_test_dir(), empty_path) == 0, - "tap empty dir enters it (%s)", library_test_dir()); + g_opened_n = 0; + tap_row("cover.cbz"); + char archive[MR_PATH_MAX]; + joinp(archive, sizeof archive, pics_dir, "cover.cbz"); + CHECK(g_opened_n == 1 && strcmp(g_opened, archive) == 0, + "tap archive opens archive book (%s)", g_opened); + CHECK(g_opened_start == MR_READER_START_RESUME, "archive uses resume start (%d)", + g_opened_start); + + /* 点目录 = 进入;空目录也进(不打开阅读器)。 */ + char sub_dir[MR_PATH_MAX]; + joinp(sub_dir, sizeof sub_dir, pics_dir, "sub"); + g_opened_n = 0; + tap_row("sub"); + CHECK(g_opened_n == 0, "tap dir does not open reader"); + CHECK(strcmp(library_test_dir(), sub_dir) == 0, "entered empty sub dir (%s)", + library_test_dir()); CHECK(library_test_rows() == 0, "empty dir shows empty list"); + CHECK(strcmp(library_test_empty_text(), "空文件夹") == 0, + "empty dir state: %s", library_test_empty_text()); - /* ---- U3:导航(逐级上溯 / 到 / 退出 / 回 TF 根) ---- */ - CHECK(library_test_go_up() == 1, "go_up from empty consumed"); - CHECK(strcmp(library_test_dir(), root) == 0, "go_up to fixture root"); + /* ---- U4:根目录锁定 ---- */ + char album_dir[MR_PATH_MAX]; + joinp(album_dir, sizeof album_dir, root, "album"); + CHECK(library_test_load(album_dir) == 1, "load album"); + CHECK(library_test_topbar_back() == 1, "back arrow shown in subdir"); + CHECK(library_test_go_up() == 1, "go_up from subdir consumed"); + CHECK(strcmp(library_test_dir(), root) == 0, "back to root"); - char deep[MR_PATH_MAX]; - joinp(deep, sizeof deep, root, "deep/sub"); - CHECK(library_test_load(deep) == 1, "load deep/sub"); - CHECK(library_test_go_up() == 1, "go_up from deep/sub"); - joinp(p, sizeof p, root, "deep"); - CHECK(strcmp(library_test_dir(), p) == 0, "now at deep"); + /* 根:系统返回 = 退出(返回 0),并保持列表不变。 */ + CHECK(library_test_go_up() == 0, "go_up at root returns 0 (app exits)"); + CHECK(strcmp(library_test_dir(), root) == 0, "still at root after exit gesture"); + CHECK(library_test_topbar_back() == 0, "no back arrow at root"); - /* 「TF卡」回根 */ - CHECK(library_test_load(deep) == 1, "reload deep/sub"); - CHECK(library_test_go_root() == 1 && strcmp(library_test_dir(), root) == 0, - "TF卡 action returns to library root"); + /* 顶栏返回:子目录点击返回箭头 = 回根;根上该区域不响应。 */ + CHECK(library_test_load(album_dir) == 1, "load album again"); + library_on_tap(20, meow_ui_statusbar_height() + 28); + CHECK(strcmp(library_test_dir(), root) == 0, "topbar back returns to root"); + int root_rows_before = library_test_rows(); + library_on_tap(20, meow_ui_statusbar_height() + 28); + CHECK(strcmp(library_test_dir(), root) == 0 && library_test_rows() == root_rows_before, + "topbar back is inert at root"); - /* 上溯到文件系统 /:go_up 在 / 返回 0(app.c 据此退出)。 - * 宿主(Termux)读不了 "/":从 /usr 返回时 load("/") 失败,仍应切到 "/" 的空列表 - * (否则会卡在 /usr 无法退出),下一次返回=退出 App。 */ - library_test_set_dir("/usr"); - CHECK(library_test_go_up() == 1, "go_up toward / consumed"); - CHECK(strcmp(library_test_dir(), "/") == 0, "reached / (unreadable root fallback)"); - CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)"); - CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture"); + /* 嵌套:deep/sub 两级返回。 */ + tap_row("deep"); + tap_row("sub"); + char deep_sub[MR_PATH_MAX], deep_dir[MR_PATH_MAX]; + joinp(deep_dir, sizeof deep_dir, root, "deep"); + joinp(deep_sub, sizeof deep_sub, root, "deep/sub"); + CHECK(strcmp(library_test_dir(), deep_sub) == 0, "entered deep/sub (%s)", library_test_dir()); + CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), deep_dir) == 0, "up to deep"); + CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), root) == 0, "up to root"); - /* ---- U3b:系统返回语义 = TF 根退出 App;顶栏「上级」保留越界浏览 ---- */ - CHECK(library_test_load(root) == 1, "load fixture root"); - CHECK(library_test_go_up() == 0, "go_up at TF root returns 0 (app exits)"); - CHECK(strcmp(library_test_dir(), root) == 0, "still at TF root after exit gesture"); - - /* 顶栏显式「上级」:访问放开,TF 根 → 其父目录(fixture root 的 parent = data)。 */ - CHECK(library_test_go_parent() == 1, "explicit up from TF root consumed"); - CHECK(strcmp(library_test_dir(), data) == 0, - "explicit up goes above TF root (%s)", library_test_dir()); - CHECK(library_test_load(root) == 1, "back to fixture root"); - - /* ---- U3c:左缘 24px 系统返回手势带不启动列表拖拽 ---- */ + /* ---- U3c:左缘 24px / 屏底热区不启动列表拖拽 ---- */ + int list_top = meow_ui_statusbar_height() + 56; int row_y = list_top + 28; library_on_touch_down(120, row_y); CHECK(library_test_drag_active() == 1, "touch inside list starts drag"); @@ -362,11 +364,10 @@ int main(void) library_on_touch_down(120, meow_ui_height() - 4); CHECK(library_test_drag_active() == 0, "bottom hotzone does not start drag"); - /* ---- U4:不可读目录 → 停留 + 提示 ---- */ - CHECK(library_test_load(root) == 1, "back to fixture root"); - char noread[MR_PATH_MAX]; - joinp(noread, sizeof noread, root, "noread"); + /* ---- U6:不可读子目录 → 停留 + 提示 ---- */ if (geteuid() != 0) { + char noread[MR_PATH_MAX]; + joinp(noread, sizeof noread, root, "noread"); CHECK(mkdir(noread, 0755) == 0, "mkdir noread"); chmod(noread, 0000); int ok = library_test_load(noread); @@ -381,7 +382,33 @@ int main(void) printf(" SKIP unreadable-dir case (running as root)\n"); } - test_cache_prune_skips_pages(); + /* ---- U7:书库不碰缓存目录(CBZ 解压页缓存原样保留,不写 bmp) ---- */ + CHECK(mr_cache_init(data) == 0, "page cache init"); + const char *cdir = mr_cache_dir(); + CHECK(cdir && cdir[0], "page cache dir: %s", cdir ? cdir : ""); + if (cdir && cdir[0]) { + char page[MR_PATH_MAX + 96]; + snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", cdir); + CHECK(touch_file(page) == 0, "create page cache file"); + int pages_before = 0; + count_bmp(cdir, &pages_before); + CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before); + + CHECK(library_test_load(root) == 1, "reload root"); + for (int i = 0; i < library_test_rows(); i++) { + meow_list_item tmp; + library_test_src(i, &tmp); + } + tap_row("album"); + tap_row("001.png"); /* 打开阅读器入口(打桩) */ + library_test_go_up(); + + int pages_after = 0; + int thumbs_after = count_bmp(cdir, &pages_after); + CHECK(access(page, R_OK) == 0, "mrpage_* survives book browsing"); + CHECK(pages_after == 1 && thumbs_after == 0, + "library writes no bmp to cache (pages=%d thumbs=%d)", pages_after, thumbs_after); + } meow_view_free(lib); meow_ui_shutdown(); diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c index 97cc465..3173cee 100644 --- a/tuku/test/test_cbz.c +++ b/tuku/test/test_cbz.c @@ -15,13 +15,12 @@ * T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。 * T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy), * 越界 open 返回 NULL,close_auto 幂等。 - * T8 书库列档(M13 单层 + 懒判定):.cbz 与文件夹册同列自然序(bookA < box < ... < - * Vol10.cbz);扫描期只成行、副标题占位,探测补位后才有页数/书标记; - * 坏档/无图片页档案仍列出但为非书(访问放开,不再隐藏); - * 不注入探测回调时档案文件不成行(M7 语义),目录仍按单层列出。 + * T8 书库列档(M18 文件管理器模型):目录 + .cbz/.zip 同列(目录优先、组内自然序, + * bookA < box < … < Vol10.cbz);扫描期一次成行,副标题是「文件夹/档案册 · 大小」, + * 不再探测页数/封面/续读角标;坏档/无图片页档案也照常列出(点开时才由 reader 判)。 * T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz), * 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。 - * T10 续读角标(M13 两段式):档案路径作为册标识,probe 补位后副标题后缀「· 续读 Pn」。 + * T10 封面解压 + 无续读角标:档案首图仍可解到缓存;书库行不再带「· 续读 Pn」。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \ @@ -716,28 +715,15 @@ static const mr_library_entry *lib_find(const mr_library *lib, const char *name) return NULL; } -/* M13:模拟 UI 后台探测补位(目录单层探测 / 档案册注入页数回调)。 */ -static void lib_probe_all(mr_library *lib) -{ - for (int i = 0; i < lib->count; i++) { - mr_library_probe p; - if (mr_library_probe_path(lib->items[i].path, lib->items[i].is_archive, - archive_pages_probe, NULL, &p) == 0) - mr_library_apply_probe(lib, i, &p); - else - mr_library_mark_error(lib, i); - } -} - static void test_library(void) { mr_test_cfg_clear(); - /* M13:扫描只做单层枚举——目录(全部列出,含无图的 box)与档案文件(.cbz/.zip) - * 都先成行、副标题占位;书/非书与页数由后台 probe 补位。 */ + /* 文件管理器模型:目录 + 档案册同列(目录优先、组内自然序),扫描期一次成行; + * 档案册只按扩展名成行,不探测页数/封面(打开时才由 reader 解档)。 */ mr_library lib; memset(&lib, 0, sizeof lib); - int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + int n = mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH); CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n); static const char *want[8] = { "bookA", "box", "broken.cbz", "emptypages.cbz", @@ -746,68 +732,33 @@ static void test_library(void) for (int i = 0; i < 8 && i < lib.count; i++) { CHECK(strcmp(lib.items[i].name, want[i]) == 0, "row[%d]=%s (want %s)", i, lib.items[i].name, want[i]); - CHECK(lib.items[i].probe_state == MR_LIB_PENDING && lib.items[i].pages == -1, - "%s pending after scan", lib.items[i].name); - CHECK(strcmp(lib.items[i].subtitle, "…") == 0, - "%s placeholder subtitle=%s", lib.items[i].name, lib.items[i].subtitle); - } - - /* 后台补位(等价 UI 的 on_tick):目录走单层目录探测,档案走注入的 zip 页数回调。 */ - for (int i = 0; i < lib.count; i++) { - mr_library_probe p; - int ok = (mr_library_probe_path(lib.items[i].path, lib.items[i].is_archive, - archive_pages_probe, NULL, &p) == 0); - CHECK(ok, "%s probed", lib.items[i].name); - if (ok) mr_library_apply_probe(&lib, i, &p); } + for (int i = 0; i < 2 && i < lib.count; i++) + CHECK(lib.items[i].kind == MR_LIB_KIND_DIR && + strcmp(lib.items[i].subtitle, "文件夹") == 0, + "%s dir row subtitle=%s", lib.items[i].name, lib.items[i].subtitle); + for (int i = 2; i < 8 && i < lib.count; i++) + CHECK(lib.items[i].kind == MR_LIB_KIND_ARCHIVE && + strncmp(lib.items[i].subtitle, "档案册 · ", strlen("档案册 · ")) == 0, + "%s archive row subtitle=%s", lib.items[i].name, lib.items[i].subtitle); const mr_library_entry *e = lib_find(&lib, "bookA"); - CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields"); - CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s", - e ? e->cover : ""); - - /* box 只含档案册:目录本身不直接含图 → 不是书(M13 不做递归/子表统计)。 */ - e = lib_find(&lib, "box"); - CHECK(e && e->has_images == 0 && e->subdirs == 0 && e->is_archive == 0, - "box fields"); - CHECK(e && strcmp(e->subtitle, "无图片") == 0, "box subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && e->cover[0] == '\0', "box cover should be empty"); + CHECK(e && e->kind == MR_LIB_KIND_DIR && e->size == 0, "bookA dir entry"); e = lib_find(&lib, "vol2.cbz"); - CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0 && - e->is_archive == 1, "vol2.cbz fields"); - CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)"); - CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s", - e ? e->path : ""); + CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && e->size > 0 && + strstr(e->path, "vol2.cbz") != NULL, + "vol2.cbz archive entry (size=%lld)", e ? e->size : -1); - e = lib_find(&lib, "Vol10.cbz"); - CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0, - "Vol10.cbz pages/subtitle"); - - /* 坏档/无图片页档案:列出但探测为非书(M13 访问放开,不再隐藏)。 */ - static const char *not_book[] = { "notabook.zip", "emptypages.cbz", + /* 坏档/无图片页档案也照常列出(书库不预判,点开时 reader 才判可读性)。 */ + static const char *all_arch[] = { "notabook.zip", "emptypages.cbz", "broken.cbz", "truncated.cbz" }; - for (unsigned i = 0; i < sizeof not_book / sizeof not_book[0]; i++) { - e = lib_find(&lib, not_book[i]); - CHECK(e && e->is_archive == 1 && e->has_images == 0 && e->pages == 0 && - strcmp(e->subtitle, "无图片") == 0, - "%s: not a book (subtitle=%s)", not_book[i], e ? e->subtitle : "(missing)"); + for (unsigned i = 0; i < sizeof all_arch / sizeof all_arch[0]; i++) { + e = lib_find(&lib, all_arch[i]); + CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE, "%s listed as archive", all_arch[i]); } CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed"); mr_library_free(&lib); - - /* 不注入探测回调:M7 纯目录书库(档案文件不成行,目录仍按 M13 单层列出)。 */ - memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 2, - "legacy scan rows=%d (want 2: bookA, box)", lib.count); - CHECK(lib.count >= 2 && strcmp(lib.items[0].name, "bookA") == 0 && - strcmp(lib.items[1].name, "box") == 0, "legacy scan rows"); - mr_library_free(&lib); } /* ============================================================ */ @@ -863,7 +814,7 @@ static void test_adjacent(void) } /* ============================================================ */ -/* T10 续读角标 + 封面 */ +/* T10 封面解压 + 无续读角标 */ /* ============================================================ */ static void test_badge_cover(void) { @@ -873,31 +824,18 @@ static void test_badge_cover(void) mr_test_cfg_clear(); mr_cont_save(vol2, 2, MR_MODE_WEBTOON); - /* M13 两段式:扫描期只有占位副标题;后台 probe 补位后才出现页数与续读角标。 */ + /* 文件管理器模型:书库行只有「档案册 · 大小」,不再有「· 续读 Pn」角标 + * (续读仍由 reader 打开该册时恢复,见 test_mode_switch_host)。 */ mr_library lib; memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, - NULL) == 8, "rescan for badge (single-layer rows)"); + CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 8, "rescan rows"); const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); - CHECK(e && strcmp(e->subtitle, "…") == 0, "vol2 placeholder before probe: %s", - e ? e->subtitle : ""); - lib_probe_all(&lib); - e = lib_find(&lib, "vol2.cbz"); - CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s", + CHECK(e && strstr(e->subtitle, "续读") == NULL, + "no continuation badge in file-manager rows: %s", e ? e->subtitle : ""); + CHECK(e && strstr(e->subtitle, "档案册") != NULL, "archive subtitle=%s", e ? e->subtitle : ""); e = lib_find(&lib, "Vol10.cbz"); - CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge"); - mr_library_free(&lib); - - /* 清除记录后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ - mr_cont_save(vol2, 0, MR_MODE_WEBTOON); - memset(&lib, 0, sizeof lib); - mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); - lib_probe_all(&lib); - e = lib_find(&lib, "vol2.cbz"); - CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone"); + CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 no badge either"); mr_library_free(&lib); /* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */ diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index d2b682f..170c7d5 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -6,8 +6,8 @@ * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; - * A6 书库扫描接线(M13 两段式):scan 只列单层、副标题占位「…」; - * probe/on_tick 补位后出现页数与「· 续读 Pn」角标。 + * A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成, + * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ @@ -159,84 +159,43 @@ static void test_adjacent(const char *root) CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); } -/* 模拟 UI 后台探测:逐行单层 probe 并补位(page>0 的书行由 format_subtitle 拼续读角标)。 */ -static void probe_all_rows(mr_library *lib) -{ - for (int i = 0; i < lib->count; i++) { - mr_library_probe p; - if (mr_library_probe_cached(lib->items[i].path, &p) == 0) - mr_library_apply_probe(lib, i, &p); - else - mr_library_mark_error(lib, i); - } -} - -/* A6 两段式(M13):scan 只做单层枚举、行副标题是占位「…」;后台 probe/on_tick 补位后 - * 才有页数与「· 续读 Pn」角标。 */ -static void test_library_badge(const char *root) +/* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标); + * 目录优先;当前层的图片行带图片下标(点图 → reader 起始页)。 */ +static void test_library_rows(const char *root) { mr_test_cfg_clear(); - char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX]; + char vol1[MR_PATH_MAX]; joinp(vol1, sizeof vol1, root, "vol1"); - joinp(vol2, sizeof vol2, root, "vol2"); - /* vol1 有续读记录(第 5 页),vol2 没有 */ + /* vol1 有续读记录(第 5 页)——书库行不应出现角标。 */ mr_cont_save(vol1, 4, MR_MODE_WEBTOON); mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); CHECK(n > 0, "library scan n=%d", n); + if (n > 0) + CHECK(lib.items[0].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[0].name); - /* 第一段:扫描期不判定,占位副标题。 */ - int seen1 = 0, seen2 = 0; + int seen1 = 0, seen_img = 0; for (int i = 0; i < lib.count; i++) { - mr_library_entry *e = &lib.items[i]; + const mr_library_entry *e = &lib.items[i]; + CHECK(e->subtitle[0] && strcmp(e->subtitle, "…") != 0, + "%s subtitle pre-generated: %s", e->name, e->subtitle); + CHECK(strstr(e->subtitle, "续读") == NULL, "%s has no badge: %s", + e->name, e->subtitle); if (strcmp(e->name, "vol1") == 0) { seen1 = 1; - CHECK(e->probe_state == MR_LIB_PENDING && e->pages == -1, - "vol1 pending after scan"); - CHECK(strcmp(e->subtitle, "…") == 0, "vol1 placeholder subtitle=%s", e->subtitle); + CHECK(e->kind == MR_LIB_KIND_DIR, "vol1 is a dir row"); } - if (strcmp(e->name, "vol2") == 0) { - seen2 = 1; - CHECK(e->probe_state == MR_LIB_PENDING && strcmp(e->subtitle, "…") == 0, - "vol2 placeholder subtitle=%s", e->subtitle); + if (strcmp(e->name, "loose.png") == 0) { + seen_img = 1; + CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row"); + CHECK(mr_library_image_index(&lib, i) == 0, "loose.png is image #0"); } } CHECK(seen1 == 1, "vol1 listed"); - CHECK(seen2 == 1, "vol2 listed"); - - /* 第二段:后台探测补位后,页数与续读角标出现。 */ - probe_all_rows(&lib); - seen1 = seen2 = 0; - for (int i = 0; i < lib.count; i++) { - mr_library_entry *e = &lib.items[i]; - if (strcmp(e->name, "vol1") == 0) { - seen1 = 1; - CHECK(e->has_images == 1 && e->pages == 1, "vol1 probed pages=%d", e->pages); - CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); - CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); - } - if (strcmp(e->name, "vol2") == 0) { - seen2 = 1; - CHECK(e->has_images == 1 && e->pages == 1, "vol2 probed pages=%d", e->pages); - CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); - CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); - } - } - CHECK(seen1 == 1, "vol1 listed after probe"); - CHECK(seen2 == 1, "vol2 listed after probe"); - mr_library_free(&lib); - - /* 记录清除后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ - mr_cont_save(vol1, 0, MR_MODE_WEBTOON); - memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "rescan"); - probe_all_rows(&lib); - for (int i = 0; i < lib.count; i++) - if (strcmp(lib.items[i].name, "vol1") == 0) - CHECK(strstr(lib.items[i].subtitle, "续读") == NULL, "cleared badge gone"); + CHECK(seen_img == 1, "loose image listed"); mr_library_free(&lib); } @@ -280,7 +239,7 @@ int main(void) test_has_images(root); test_adjacent(root); - test_library_badge(root); + test_library_rows(root); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 2b4e2a6..1fcb9a3 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -1,11 +1,11 @@ /* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。 * - * 覆盖: - * T1 自然排序:数字段数值序(page2 -1;max_depth 忽略; * T4 阅读方向状态(logic/reader.c)。 * * 编译(在 tuku/ 下): @@ -80,6 +80,13 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } +static int find_index(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return i; + return -1; +} + static void test_natural(void) { CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); @@ -91,6 +98,7 @@ static void test_natural(void) CHECK(mr_natural_cmp("a", "a1") < 0, "prefix shorter first"); CHECK(mr_natural_cmp("abc", "abc") == 0, "identical equal"); CHECK(mr_natural_cmp("ABC", "abc") != 0, "case-only tie is deterministic"); + CHECK(mr_natural_cmp("a.jpg", "A.jpg") > 0, "case tie falls back to raw bytes"); CHECK(mr_natural_cmp("A2", "a10") < 0, "case-insensitive primary then numeric"); } @@ -104,6 +112,10 @@ static void test_ext(void) CHECK(mr_path_is_image("a.txt") == 0, "txt unsupported"); CHECK(mr_path_is_image("png") == 0, "no extension"); CHECK(mr_path_is_image(".png") == 0, "dotfile-ish no stem"); + CHECK(mr_path_is_archive("a.cbz") == 1, "cbz"); + CHECK(mr_path_is_archive("a.ZIP") == 1, "ZIP uppercase"); + CHECK(mr_path_is_archive("a.7z") == 0, "7z unsupported"); + CHECK(mr_path_is_archive("cbz") == 0, "no extension archive"); } static void test_parent(void) @@ -150,54 +162,99 @@ static void test_scan(void) snprintf(p, sizeof p, "%s/MeowAPPs", root); mkdir_p(p); snprintf(p, sizeof p, "%s/MeowAPPs/m.png", root); touch_file(p); - snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); /* 普通文件不成为行 */ + /* 当前层文件:图片与档案册都成行;txt/隐藏文件不成行。 */ + snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); + snprintf(p, sizeof p, "%s/cover.JPG", root); touch_file(p); + snprintf(p, sizeof p, "%s/book.cbz", root); touch_file(p); + snprintf(p, sizeof p, "%s/bundle.zip", root);touch_file(p); + snprintf(p, sizeof p, "%s/notes.txt", root); touch_file(p); + snprintf(p, sizeof p, "%s/.hidden.png", root); touch_file(p); + + char link_path[MR_PATH_MAX], link_target[MR_PATH_MAX]; + snprintf(link_target, sizeof link_target, "%s/root.png", root); + snprintf(link_path, sizeof link_path, "%s/link.png", root); + symlink(link_target, link_path); /* 有权限才建;不列 */ mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); - CHECK(n == 7, "scan count=%d (want 7: all direct subdirs)", n); + CHECK(n == 11, "scan count=%d (want 11: 7 dirs + 4 files)", n); CHECK(lib.count == n, "count matches"); CHECK(lib.truncated == 0, "not truncated"); - if (lib.count == 7) { - static const char *want[7] = { - "Empty", "MeowAPPs", "Nested", "Series 2", "Series 02", "Series 3", "Series 10" + if (lib.count == 11) { + static const char *want[11] = { + "Empty", "MeowAPPs", "Nested", "Series 2", "Series 02", "Series 3", "Series 10", + "book.cbz", "bundle.zip", "cover.JPG", "root.png" }; - for (int i = 0; i < 7; i++) + for (int i = 0; i < 11; i++) CHECK(strcmp(lib.items[i].name, want[i]) == 0, "sort[%d]=%s got %s", i, want[i], lib.items[i].name); + for (int i = 0; i < 7; i++) + CHECK(lib.items[i].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[i].name); } - /* 访问放开:系统目录/空目录/无图目录都列出;隐藏项/文件不列。 */ - CHECK(find_entry(&lib, "MeowAPPs") != NULL, "system dir listed"); - CHECK(find_entry(&lib, "Empty") != NULL, "empty dir listed"); - CHECK(find_entry(&lib, "Series 3") != NULL, "no-image dir listed"); - CHECK(find_entry(&lib, "Nested") != NULL, "intermediate dir listed"); + /* 行类型与副标题(扫描期生成一次,不是占位)。 */ + const mr_library_entry *e = find_entry(&lib, "Empty"); + CHECK(e && e->kind == MR_LIB_KIND_DIR && strcmp(e->subtitle, "文件夹") == 0, + "dir subtitle=%s", e ? e->subtitle : ""); + e = find_entry(&lib, "root.png"); + CHECK(e && e->kind == MR_LIB_KIND_IMAGE && e->size == 1 && + strncmp(e->subtitle, "图片 · ", strlen("图片 · ")) == 0, + "image subtitle=%s size=%lld", e ? e->subtitle : "", e ? e->size : -1); + e = find_entry(&lib, "book.cbz"); + CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && + strncmp(e->subtitle, "档案册 · ", strlen("档案册 · ")) == 0, + "archive subtitle=%s", e ? e->subtitle : ""); + + /* 排除项:普通文件/隐藏项/隐藏目录/符号链接。 */ + CHECK(find_entry(&lib, "notes.txt") == NULL, "plain text file excluded"); CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); - CHECK(find_entry(&lib, "root.png") == NULL, "loose image file excluded"); + CHECK(find_entry(&lib, ".hidden.png") == NULL, "hidden file excluded"); + CHECK(find_entry(&lib, "link.png") == NULL, "symlink excluded"); + CHECK(find_entry(&lib, "a.png") == NULL, "no recursion into subdirs"); + CHECK(find_entry(&lib, "Deeper") == NULL, "no recursion into subdirs (deeper)"); - /* 首帧不做懒判定:所有行待探测(副标题占位)。 */ - for (int i = 0; i < lib.count; i++) { - CHECK(lib.items[i].pages == -1, "%s pages pending", lib.items[i].name); - CHECK(lib.items[i].has_images == -1, "%s book state pending", lib.items[i].name); - CHECK(lib.items[i].probe_state == MR_LIB_PENDING, "%s probe pending", lib.items[i].name); - CHECK(strcmp(lib.items[i].subtitle, "…") == 0, "%s subtitle placeholder", lib.items[i].name); - } + /* 图片下标 = 本层图片自然序(书库点图 → 阅读器起始页的契约)。 */ + int i_cover = find_index(&lib, "cover.JPG"); + int i_root = find_index(&lib, "root.png"); + CHECK(mr_library_image_index(&lib, i_cover) == 0, "cover.JPG is image #0"); + CHECK(mr_library_image_index(&lib, i_root) == 1, "root.png is image #1"); + CHECK(mr_library_image_index(&lib, find_index(&lib, "book.cbz")) == -1, + "archive has no image index"); + CHECK(mr_library_image_index(&lib, find_index(&lib, "Empty")) == -1, + "dir has no image index"); + CHECK(mr_library_image_index(&lib, -1) == -1 && mr_library_image_index(&lib, 99) == -1, + "out-of-range image index"); + + /* 行合成:纯内存,返回扫描期生成的字符串。 */ + mr_library_row row; + CHECK(mr_library_row_at(&lib, 0, &row) == 1 && row.index == 0 && + row.kind == MR_LIB_KIND_DIR && row.title && row.subtitle && + strcmp(row.subtitle, "文件夹") == 0, "row 0 = first dir"); + CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated"); + CHECK(mr_library_row_at(&lib, -1, &row) == 0, "row -1 invalid"); /* max_depth 参数只为兼容旧调用保留:不再递归,结果一致。 */ mr_library lib1; memset(&lib1, 0, sizeof lib1); - CHECK(mr_library_scan(&lib1, root, 1) == 7, "max_depth ignored (single layer)"); + CHECK(mr_library_scan(&lib1, root, 1) == 11, "max_depth ignored (single layer)"); mr_library_free(&lib1); mr_library_free(&lib); - /* 不可读目录:返回 -1,lib 可安全 free。 */ + /* 不可读目录 / 不是目录:返回 -1,lib 可安全 free。 */ mr_library bad; memset(&bad, 0, sizeof bad); CHECK(mr_library_scan(&bad, "/no/such/dir/tuku", MR_LIB_MAX_DEPTH) == -1, "missing dir -> -1"); CHECK(bad.count == 0, "missing dir empty"); mr_library_free(&bad); + snprintf(p, sizeof p, "%s/root.png", root); + memset(&bad, 0, sizeof bad); + CHECK(mr_library_scan(&bad, p, MR_LIB_MAX_DEPTH) == -1, "file (not dir) -> -1"); + CHECK(bad.count == 0, "file scan empty"); + mr_library_free(&bad); + rm_rf(root); } diff --git a/tuku/test/test_open_book_host.c b/tuku/test/test_open_book_host.c index c2b0529..dc3a503 100644 --- a/tuku/test/test_open_book_host.c +++ b/tuku/test/test_open_book_host.c @@ -1,11 +1,14 @@ /* test_open_book_host.c — 书库点击 → 阅读器打开的宿主集成回归(需 SDK 源码树)。 * - * 复现用户报告:「父文件夹有封面图片、子文件夹也有图片,点开父文件夹报无法打开此册」。 - * 用合成夹具(真实 BMP 当封面/子图,不读用户相册)走完整链路: - * library 列表 → 行点击 → mr_reader_open_dir → reader on_enter → mr_book_open_auto - * 并断言父目录以「书」打开(count=1)且没有进入 open_err。 + * M18 文件管理器模型:点目录=进入;点图片=从该图开始读(mr_reader_open_dir_at(目录, 下标)); + * 点档案册=打开档案册。本测试用合成夹具(真实 BMP,不读用户相册)走完整链路: + * library 列表 → 点图片行 → mr_reader_open_dir_at → reader on_enter → mr_book_open_auto + * 并断言「点哪张就开哪张」:阅读器当前页 == 该书库行在目录图片自然序里的下标。 * - * 另覆盖:封面图很大时打开仍成功(打开只枚举,不解码);同目录二次打开/离开重开。 + * 另覆盖: + * - 目录行只进目录,不打开阅读器; + * - 阅读页已在本页时再次 mr_reader_open_dir_at 能换册/换页重进(SDK 忽略重复 push 的坑); + * - 离开阅读器回书库后再次点图(同目录二次打开)。 * * 编译:见 test/run_tests.sh(与 test_mode_switch_host 同口径)。 */ @@ -25,7 +28,6 @@ void library_test_set_root(const char *root); const char *library_test_dir(void); int library_test_rows(void); int library_test_src(int idx, meow_list_item *item); -int library_test_worker_on(void); /* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */ int mr_reader_ui_mode(void); @@ -116,22 +118,6 @@ static int rm_rf(const char *path) return rmdir(path); } -static void wait_ready(int ms) -{ - for (int t = 0; t < ms / 10; t++) { - library_on_tick(); - int ready = 1; - int rows = library_test_rows(); - for (int i = 0; i < rows; i++) { - meow_list_item it; - if (!library_test_src(i, &it)) continue; - if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; } - } - if (ready) return; - usleep(10000); - } -} - static int row_index_for(const char *name) { int rows = library_test_rows(); @@ -147,10 +133,25 @@ static int row_index_for(const char *name) static void tap_row(const char *name) { int idx = row_index_for(name); + if (idx < 0) { CHECK(0, "row %s not found", name); return; } int list_top = meow_ui_statusbar_height() + 56; library_on_tap(120, list_top + idx * 56 + 28); } +/* 某张图片行在本目录图片自然序里的下标 = 排在前面的「图片 · 」行数。 */ +static int expected_image_index(const char *name) +{ + int ti = row_index_for(name); + if (ti < 0) return -1; + int n = 0; + for (int i = 0; i < ti; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.subtitle && strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0) n++; + } + return n; +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -161,25 +162,27 @@ int main(void) if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } setenv("MEOW_DATA_DIR", data, 1); - char root[MR_PATH_MAX], parent[MR_PATH_MAX], sub[MR_PATH_MAX], p[MR_PATH_MAX]; + char root[MR_PATH_MAX], pics[MR_PATH_MAX], other[MR_PATH_MAX], p[MR_PATH_MAX]; snprintf(root, sizeof root, "%s/library", data); - snprintf(parent, sizeof parent, "%s/Pictures", root); - if (mkdir_p(parent) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + snprintf(pics, sizeof pics, "%s/Pictures", root); + snprintf(other, sizeof other, "%s/Other", root); + if (mkdir_p(pics) != 0 || mkdir_p(other) != 0) { printf("SKIP: mkdir failed\n"); return 0; } - /* 父目录:一张封面图 + 两个含图子目录(用户报告的结构)。 */ - snprintf(p, sizeof p, "%s/cover.jpg", parent); /* 扩展名即可;打开只枚举不解码 */ - FILE *f = fopen(p, "wb"); - if (!f) { printf("SKIP: cover write failed\n"); return 0; } - fputs("x", f); - fclose(f); - snprintf(sub, sizeof sub, "%s/2023", parent); - mkdir_p(sub); - snprintf(p, sizeof p, "%s/a.bmp", sub); - write_bmp24(p, 40, 60); - snprintf(sub, sizeof sub, "%s/2024", parent); - mkdir_p(sub); - snprintf(p, sizeof p, "%s/b.bmp", sub); - write_bmp24(p, 40, 60); + /* Pictures:6 张图(数字序号/大小写/前导零混排)+ 1 个普通文件(不成行)。 */ + const char *imgs[] = { "A.bmp", "a.bmp", "img2.bmp", "img10.bmp", + "page3.bmp", "Page03.bmp" }; + for (unsigned i = 0; i < sizeof imgs / sizeof imgs[0]; i++) { + snprintf(p, sizeof p, "%s/%s", pics, imgs[i]); + if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } + } + snprintf(p, sizeof p, "%s/notes.txt", pics); + { FILE *f = fopen(p, "wb"); if (f) { fputs("x", f); fclose(f); } } + + /* Other:4 张图,用于「已在本页时换册重进」。 */ + for (int i = 0; i < 4; i++) { + snprintf(p, sizeof p, "%s/o%d.bmp", other, i + 1); + if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } + } meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } @@ -190,46 +193,52 @@ int main(void) meow_view_page_push(ACT_LIBRARY); CHECK(library_test_load(root) == 1, "load fixture root"); - wait_ready(3000); - int ip = row_index_for("Pictures"); - meow_list_item it; - CHECK(ip >= 0 && library_test_src(ip, &it), "Pictures row present"); - CHECK(it.subtitle && strcmp(it.subtitle, "1 page") == 0, - "Pictures probed as book with direct cover (subtitle=%s)", - it.subtitle ? it.subtitle : ""); - - /* 点击父目录 → 进阅读器;cover 是唯一直接子图 → count=1,不能是 open_err。 */ + /* 点目录 = 进入目录,不打开阅读器。 */ tap_row("Pictures"); - CHECK(meow_ui_activity_active() == ACT_READER, "tap opens reader"); - CHECK(strcmp(mr_reader_dir(), parent) == 0, "reader dir=%s", mr_reader_dir()); - CHECK(mr_reader_ui_open_err() == 0, "parent folder opens without open_err"); - CHECK(mr_reader_ui_count() == 1, "parent book pages=%d (want 1)", - mr_reader_ui_count()); - CHECK(library_test_worker_on() == 0, - "library background decode stopped while reader is active"); - for (int i = 0; i < 30; i++) { usleep(10 * 1000); } + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "tap dir stays in library"); + CHECK(strcmp(library_test_dir(), pics) == 0, "entered Pictures (%s)", library_test_dir()); + CHECK(library_test_rows() == 6, "Pictures rows=%d (6 images, txt excluded)", + library_test_rows()); + + /* 点 img10.bmp:阅读器从该图开始(count=6, page=它在该目录的自然序下标)。 */ + int want = expected_image_index("img10.bmp"); + CHECK(want == 3, "img10.bmp natural image index=%d (want 3)", want); + tap_row("img10.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "tap image opens reader"); + CHECK(strcmp(mr_reader_dir(), pics) == 0, "reader dir=%s", mr_reader_dir()); + CHECK(mr_reader_ui_open_err() == 0, "open has no error"); + CHECK(mr_reader_ui_count() == 6, "reader pages=%d (want 6)", mr_reader_ui_count()); + CHECK(mr_reader_ui_page() == want, "reader starts at selected image: page=%d (want %d)", + mr_reader_ui_page(), want); + + /* 让首帧解码 tick 跑一遍,确认不会因坏夹具进入 open_err。 */ + for (int i = 0; i < 20; i++) usleep(10 * 1000); CHECK(mr_reader_ui_open_err() == 0, "still no open_err after decode tick"); - /* 返回书库 → 后台线程恢复,再次点开(同目录二次打开 + 续读恢复)。 */ + /* 已在本页:直接换册重进(M17 遗留修复,SDK 会忽略重复 push)。 */ + mr_reader_open_dir_at(other, 2); + CHECK(meow_ui_activity_active() == ACT_READER, "switch keeps reader active"); + CHECK(strcmp(mr_reader_dir(), other) == 0, "switched reader dir=%s", mr_reader_dir()); + CHECK(mr_reader_ui_count() == 4, "switched book pages=%d (want 4)", mr_reader_ui_count()); + CHECK(mr_reader_ui_page() == 2, "switched book page=%d (want 2)", mr_reader_ui_page()); + + /* 返回书库 → 再点另一张图(同目录二次打开 + 显式起始页优先于续读)。 */ meow_view_page_pop(); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library"); - CHECK(library_test_worker_on() == 1, "library background decode restarted on return"); - tap_row("Pictures"); - CHECK(mr_reader_ui_open_err() == 0, "reopen parent without open_err"); - CHECK(mr_reader_ui_count() == 1, "reopen pages=%d", mr_reader_ui_count()); + CHECK(strcmp(library_test_dir(), pics) == 0, "library still in Pictures"); + + tap_row("page3.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "reopen reader"); + CHECK(mr_reader_ui_open_err() == 0, "reopen no error"); + CHECK(mr_reader_ui_count() == 6, "reopen pages=%d", mr_reader_ui_count()); + int want2 = expected_image_index("page3.bmp"); + CHECK(want2 == 4, "page3.bmp natural image index=%d (want 4)", want2); + CHECK(mr_reader_ui_page() == want2, "reopen starts at page=%d (want %d)", + mr_reader_ui_page(), want2); meow_view_page_pop(); - /* 封面是大图也不影响打开(打开只枚举文件名)。 */ - snprintf(p, sizeof p, "%s/cover.bmp", parent); - CHECK(write_bmp24(p, 400, 600) == 0, "write large cover"); - CHECK(library_test_load(root) == 1, "reload after cover change"); - wait_ready(3000); - tap_row("Pictures"); - CHECK(mr_reader_ui_open_err() == 0, "open with large cover has no open_err"); - CHECK(mr_reader_ui_count() == 2, "cover.jpg + cover.bmp pages=%d (want 2)", - mr_reader_ui_count()); - meow_view_page_pop(); + /* 档案册路径(无 zip 夹具时只验证入口不被当成目录):点 txt 不存在行;跳过。 */ meow_view_free(reader); meow_view_free(lib); diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h index 4dcb027..9b6bac3 100644 --- a/tuku/tuku_internal.h +++ b/tuku/tuku_internal.h @@ -8,7 +8,7 @@ * 页面:ACT_LIBRARY(0) 根页(保留状态栏)、ACT_READER(1) 全屏、ACT_SETTINGS(2)、ACT_LOCK(3)。 * 并行开发接缝(后续任务只填自己的文件,不改 app.c/internal.h): * - M4 阅读器:ui/reader/reader.c 实现,方向状态用 logic/reader 的 mr_reader_get/set_direction; - * 书库点入含图目录经 mr_reader_open_dir(path) 进入本页。 + * 书库列表点图片/档案册经 mr_reader_open_dir_at()(ui/reader/reader.h)进入本页。 * - M5 锁屏/PIN:ui/lock/lock.c 实现 lock_view_should_show();本批桩返回 0(不锁)。 */ #ifndef TUKU_INTERNAL_H @@ -27,7 +27,7 @@ #define ACT_SETTINGS 2 #define ACT_LOCK 3 -#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库根 = TF 卡根 */ +#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库浏览根 = TF 卡根(不允许上溯到其上级) */ /* ---- 视图控制器(biz-UI 绑定点) ---- */ typedef struct mr_page_ctrl { @@ -35,16 +35,15 @@ typedef struct mr_page_ctrl { int active; } mr_page_ctrl; -/* ---- 书库页(ui/library/library.c) ---- */ +/* ---- 书库页(ui/library/library.c,文件管理器模型) ---- */ meow_view *library_view_create(void); -int library_at_root(void); /* 1=当前在书库根(生产=MR_ROOT_DIR,宿主测试可改根) */ -int library_go_up(void); /* 系统返回语义:逐级上移;TF 根/"/" 返回 0(app.c 据此退出) */ -int library_go_parent(void); /* 顶栏显式「上级」:访问放开,可越过 TF 根到 '/' */ +int library_at_root(void); /* 1=当前在浏览根(生产=MR_ROOT_DIR,宿主测试可改根) */ +int library_go_up(void); /* 系统返回语义:逐级上移;浏览根返回 0(app.c 据此退出) */ void library_on_tap(int x, int y); void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ void library_on_touch_down(int x, int y); void library_on_touch_move(int x, int y); -void library_on_tick(void); /* 收下后台缩略图结果并局部补位 */ +void library_on_tick(void); /* 16ms tick:提示横幅到期清除 */ /* ---- 阅读器页(ui/reader/reader.c,M4 填充) ---- */ meow_view *reader_view_create(void); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 2514112..f7f7322 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,130 +1,49 @@ -/* ui/library/library.c — 图库浏览页(ACT_LIBRARY): +/* ui/library/library.c — 图库浏览页(ACT_LIBRARY):文件管理器模型。 * - * TopAppBar(标题 + 返回 + 「TF卡」回根 + ⚙)+ meow_listview(src2 行数据源)。 + * TopAppBar(标题 + 返回 + 刷新 + ⚙)+ meow_listview(src2 行数据源)。 * - * 浏览语义(M13,用户要求:性能 + 访问放开): - * - 首帧只做**单层**枚举(logic/library.c:一次 opendir/readdir+lstat), - * 不做任何递归「子树含图」探测;列表行 = 当前目录的直接子目录(M11 起还包括 - * .cbz/.zip 档案册,与目录自然序混排); - * - 系统目录/空目录/无图目录都列出且可进入;左缘返回按系统语义逐级上移, - * **到 TF 根即退出 App**(不再上溯系统目录);TF 根以外的上级浏览走顶栏「上级」动作; - * - 「直接含图目录=一本书」与档案册页数改为后台懒算:行仍在 PENDING 时副标题「…」, - * 后台线程用 mr_library_probe_path(单层 + mtime/size 失效缓存)算出页数/首图, - * on_tick 补位后只 invalidate 列表视口——首帧/滚动/命中判定都不读子目录/档案; - * - 不可读/不存在的目录:停留在原目录,顶部横幅提示 2.5s。 + * 浏览语义(M18,用户要求「完全像文件管理器」): + * - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序; + * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at(目录, 图片下标)), + * 点档案册=打开档案册(第 1 页/续读); + * - 首帧只做**单层**枚举(一次 opendir/readdir+lstat):没有「目录含图=一本书」的探测、 + * 页数、封面、角标,也没有懒算线程/缩略图解码——行副标题在扫描期生成一次; + * - 根目录锁定 TF 卡根(MR_ROOT_DIR):顶栏返回在根隐藏,library_go_up 在根返回 0 + * (app.c 据此退出 App),不存在越过根上溯的导航; + * - 根目录不可访问(未插卡/挂载失败)→ 列表空态「未检测到 SD 卡」+ 顶栏刷新重试; + * 重进本页也会自动重扫。子目录不可读 → 停留原目录 + 顶部提示横幅。 * - * 行绑定(列表 src2 模式):meow_listview_create(..., rows, lib_src, ...) 每帧/每次 - * invalidate 对可见行重新取行;lib_src 只读内存里的 g_lib + 缩略图缓存,并把「待探测/ - * 待缩略图」请求投递到有界队列(不做 IO),因此懒算结果补位后下一帧即可上屏。 - * - * 缩略图:后台线程 meow_img_downscale 到 96px,落磁盘缓存 MeowData//cache - * (BMP,文件名含源 mtime+size,源变化自动失效;≥64 张按 mtime LRU 删除)。 - * 档案册(M11)封面=档内首图:先解压到 page_cache,再按普通图片解码。 + * 行绑定(src2 模式):meow_listview_create(..., lib_src, ...) 每帧对可见行取一次行数据; + * lib_src 只读 g_lib 内存(指针直出,不做 IO/格式化),滚动与点击路径零解码零文件访问。 * * 绘制/颜色全部取 meow_theme_get() 语义色;主题切换由 app.c 的 listener 整帧 invalidate。 */ #include "tuku_internal.h" -#include "logic/reader/page_source.h" -#include "logic/reader/page_cache.h" +#include "ui/reader/reader.h" -#include #include -#include -#include #include -#include #include -#include -#include -#include -#include -#define MR_THUMB_MAX 24 -#define MR_THUMB_DIM 96 -#define MR_THUMB_DECODE_DIM 256 /* 缩略图源图预算解码边长(峰值 ~1MB;整幅解码可达数十 MB) */ -#define MR_TOPAPPBAR_H 56 -#define MR_DEFAULT_ROW_H 56 -#define MR_CACHE_MAX_FILES 64 -#define MR_HINT_H 44 -#define MR_HINT_MS 2500 - -#define MR_TREQ_MAX 64 -#define MR_TRES_MAX 16 -#define MR_PQ_MAX 128 -#define MR_PRES_MAX 48 - -typedef struct thumb_req { - int gen; - int idx; - char cover[MR_PATH_MAX]; -} thumb_req; - -typedef struct thumb_res { - int gen; - int idx; - meow_img *img; -} thumb_res; - -typedef struct probe_job { - int gen; - int idx; - int is_archive; - char path[MR_PATH_MAX]; -} probe_job; - -typedef struct probe_res { - int gen; - int idx; - int is_archive; - char path[MR_PATH_MAX]; - int ok; - mr_library_probe probe; -} probe_res; +#define MR_TOPAPPBAR_H 56 +#define MR_HINT_H 44 +#define MR_HINT_MS 2500 static mr_page_ctrl g_ctrl; static mr_library g_lib; static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR; -static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 「TF卡」回根目标(宿主测试可改) */ +static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 浏览根(生产=MR_ROOT_DIR,宿主测试可改) */ static meow_widget *g_tb; static meow_widget *g_lv; static meow_scroll *g_scroll; static int g_list_x, g_list_y, g_list_w, g_list_h; -static int g_tb_home_idx = -1; +static int g_act_refresh = -1; +static int g_act_settings = -1; +static int g_tb_back; /* 顶栏返回箭头是否显示(根目录隐藏) */ +static int g_sd_missing; /* 1=根目录不可访问(未检测到 SD 卡的空态) */ -static struct { - int idx; - meow_img *img; -} g_thumbs[MR_THUMB_MAX]; -static int g_thumb_n; - -/* ---- 后台工作线程:探测队列(优先)+ 缩略图队列 ---- */ -static pthread_t g_worker; -static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; -static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; -static int g_worker_on; -static int g_worker_stop; -static int g_gen; - -static probe_job g_pq[MR_PQ_MAX]; -static int g_pq_head, g_pq_tail, g_pq_n; -static char g_probe_inflight[MR_PATH_MAX]; - -static probe_res g_pres[MR_PRES_MAX]; -static int g_pres_n; - -static thumb_req g_treq[MR_TREQ_MAX]; -static int g_treq_head, g_treq_tail, g_treq_n; -static char g_thumb_inflight[MR_PATH_MAX]; - -static thumb_res g_tres[MR_TRES_MAX]; -static int g_tres_n; - -/* ---- 磁盘缩略图缓存(与解压页缓存同目录) ---- */ -static char g_cache_dir[MR_PATH_MAX]; -static int g_cache_on; - -/* ---- 顶部提示横幅(不可读目录等) ---- */ +/* ---- 顶部提示横幅(子目录不可读等) ---- */ static int g_hint_id; static char g_hint[160]; static uint64_t g_hint_until; @@ -133,351 +52,19 @@ static int g_hint_x, g_hint_y, g_hint_w, g_hint_h; static int g_drag; static int g_dragged; static int g_drag_last_y; +static int g_press_row; /* 按下行(-1=无):按下高亮,拖动超阈值清除 */ +static int g_press_delta; -/* 书库扫描/后台探测的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ -static int cbz_archive_pages(const char *path, void *ud) +/* 清除按下高亮(抬手/拖动/切目录时调用)。 */ +static void press_clear(void) { - (void)ud; - return mr_zip_count_pages(path); + if (g_lv) meow_listview_set_sel(g_lv, -1); + g_press_row = -1; + g_press_delta = 0; } /* ============================================================ */ -/* 磁盘缓存(BMP 原始缩略图;文件名含源 mtime+size 做失效) */ -/* ============================================================ */ -static unsigned long long fnv1a64(const char *s) -{ - unsigned long long h = 1469598103934665603ULL; - for (const unsigned char *p = (const unsigned char *)s; *p; p++) { - h ^= (unsigned long long)*p; - h *= 1099511628211ULL; - } - return h; -} - -static void cache_init(void) -{ - const char *data = meow_config_dir(); - if (!data || !data[0]) return; - - /* 缩略图缓存与解压页缓存同目录(MeowData//cache)。目录推导、建立与 - * 启动清孤儿统一交给 logic/reader/page_cache,避免两处目录规则分叉。 */ - if (mr_cache_init(data) != 0 || mr_cache_dir()[0] == '\0') return; - snprintf(g_cache_dir, sizeof g_cache_dir, "%s", mr_cache_dir()); - g_cache_on = 1; -} - -static void cache_path(char *out, int outsz, const char *cover, const struct stat *st) -{ - unsigned long long h = fnv1a64(cover); - snprintf(out, (size_t)outsz, "%s/%016llx_%lld_%lld.bmp", g_cache_dir, h, - (long long)st->st_mtime, (long long)st->st_size); -} - -/* LRU:缓存文件数超过 MR_CACHE_MAX_FILES 时删最旧(读命中会 utimes 刷新 mtime)。 - * 每次只删一个最旧文件、最多扫 8 轮,避免在 14MB 内存口径下分配大数组。 */ -static void cache_prune(void) -{ - if (!g_cache_on || !g_cache_dir[0]) return; - for (int pass = 0; pass < 8; pass++) { - DIR *d = opendir(g_cache_dir); - if (!d) return; - int count = 0, found = 0; - char oldest[MR_PATH_MAX + 96]; - oldest[0] = '\0'; - time_t oldest_t = 0; - struct dirent *de; - while ((de = readdir(d)) != NULL) { - /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 - * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 - * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ - if (mr_cache_is_page_name(de->d_name)) continue; - const char *dot = strrchr(de->d_name, '.'); - if (!dot || strcmp(dot, ".bmp") != 0) continue; - char full[MR_PATH_MAX + 96]; - int n = snprintf(full, sizeof full, "%s/%s", g_cache_dir, de->d_name); - if (n <= 0 || n >= (int)sizeof full) continue; - struct stat st; - if (stat(full, &st) != 0) continue; - count++; - if (!found || st.st_mtime < oldest_t) { - found = 1; - oldest_t = st.st_mtime; - snprintf(oldest, sizeof oldest, "%s", full); - } - } - closedir(d); - if (count <= MR_CACHE_MAX_FILES || !found) break; - unlink(oldest); - } -} - -static void put_u16(unsigned char *p, unsigned v) -{ - p[0] = (unsigned char)(v & 0xFF); - p[1] = (unsigned char)((v >> 8) & 0xFF); -} -static void put_u32(unsigned char *p, uint32_t v) -{ - p[0] = (unsigned char)(v & 0xFF); - p[1] = (unsigned char)((v >> 8) & 0xFF); - p[2] = (unsigned char)((v >> 16) & 0xFF); - p[3] = (unsigned char)((v >> 24) & 0xFF); -} - -/* 写 32bpp BI_RGB BMP(stb_image 可读回)。失败返回 -1(缓存只是加速,静默降级)。 */ -static int write_bmp(const char *path, const meow_img *im) -{ - int w = meow_img_w(im), h = meow_img_h(im); - const unsigned char *px = meow_img_pixels(im); - if (!path || w <= 0 || h <= 0 || w > MR_THUMB_DIM || h > MR_THUMB_DIM || !px) return -1; - - uint32_t stride = (uint32_t)w * 4; - uint32_t imgsz = stride * (uint32_t)h; - unsigned char fh[14] = { 0 }; - unsigned char ih[40] = { 0 }; - fh[0] = 'B'; fh[1] = 'M'; - put_u32(fh + 2, 14 + 40 + imgsz); - put_u32(fh + 10, 14 + 40); - put_u32(ih + 0, 40); - put_u32(ih + 4, (uint32_t)w); - put_u32(ih + 8, (uint32_t)h); - put_u16(ih + 12, 1); - put_u16(ih + 14, 32); - put_u32(ih + 20, imgsz); - put_u32(ih + 24, 2835); - put_u32(ih + 28, 2835); - - FILE *f = fopen(path, "wb"); - if (!f) return -1; - int ok = (fwrite(fh, 1, sizeof fh, f) == sizeof fh && - fwrite(ih, 1, sizeof ih, f) == sizeof ih); - unsigned char row[MR_THUMB_DIM * 4]; - for (int y = h - 1; ok && y >= 0; y--) { - const unsigned char *src = px + (size_t)y * (size_t)w * 4; - for (int x = 0; x < w; x++) { - row[x * 4 + 0] = src[x * 4 + 2]; - row[x * 4 + 1] = src[x * 4 + 1]; - row[x * 4 + 2] = src[x * 4 + 0]; - row[x * 4 + 3] = src[x * 4 + 3]; - } - if (fwrite(row, 1, stride, f) != stride) ok = 0; - } - if (fclose(f) != 0) ok = 0; - if (!ok) unlink(path); - return ok ? 0 : -1; -} - -/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 - * 档案册(M11)的 cover 是 .cbz/.zip 路径:先解压档内首图到 page_cache, - * 再按普通图片路径解码;缩略图缓存键仍用档案本身(mtime/size 变化即失效)。 */ -static meow_img *thumb_load(const char *cover) -{ - struct stat st; - if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; - - char extracted[MR_CACHE_FILE_MAX]; - const char *src = cover; - if (mr_path_is_archive(cover)) { - if (!mr_zip_cover_path(cover, extracted, (int)sizeof extracted)) return NULL; - src = extracted; - } - - char cp[MR_PATH_MAX + 96]; - if (g_cache_on && g_cache_dir[0]) { - cache_path(cp, (int)sizeof cp, cover, &st); - if (access(cp, R_OK) == 0) { - meow_img *im = meow_img_load_file(cp); - if (im) { - utimes(cp, NULL); /* LRU 读命中刷新 */ - return im; - } - unlink(cp); /* 损坏缓存 */ - } - } - - /* 预算解码:封面可能是 12MP 手机照片,整幅解码峰值 ~48MB,会在低内存设备上 - * 挤掉随后 reader 打开所需的 opendir/malloc(用户报告「点开父文件夹=无法打开此册」)。 - * 缩略图最终只要 96px,先解到 ≤MR_THUMB_DECODE_DIM 再精修,视觉无损、峰值 ~1MB。 */ - meow_img *im = meow_img_load_file_dim(src, MR_THUMB_DECODE_DIM); - if (!im) return NULL; - meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); - if (small) { meow_img_free(im); im = small; } - - if (g_cache_on && g_cache_dir[0]) { - if (write_bmp(cp, im) == 0) cache_prune(); - } - return im; -} - -static void probe_push_res_locked(const probe_job *job, int ok, const mr_library_probe *p) -{ - if (g_pres_n >= MR_PRES_MAX) return; - probe_res *r = &g_pres[g_pres_n++]; - r->gen = job->gen; - r->idx = job->idx; - r->is_archive = job->is_archive; - snprintf(r->path, sizeof r->path, "%s", job->path); - r->ok = ok; - if (ok && p) r->probe = *p; - else memset(&r->probe, 0, sizeof r->probe); -} - -static void thumb_push_res_locked(const thumb_req *req, meow_img *im) -{ - if (g_tres_n >= MR_TRES_MAX) { meow_img_free(im); return; } - thumb_res *r = &g_tres[g_tres_n++]; - r->gen = req->gen; - r->idx = req->idx; - r->img = im; -} - -static void *lib_worker(void *arg) -{ - (void)arg; - for (;;) { - int kind = 0; /* 1=探测 2=缩略图 */ - probe_job pj; - thumb_req tr; - - pthread_mutex_lock(&g_lock); - while (!g_worker_stop && g_pq_n == 0 && g_treq_n == 0) - pthread_cond_wait(&g_cv, &g_lock); - if (g_worker_stop) { pthread_mutex_unlock(&g_lock); break; } - if (g_pq_n > 0) { /* 探测优先:页数/角标比重缩略图更影响可读性 */ - kind = 1; - pj = g_pq[g_pq_head]; - g_pq_head = (g_pq_head + 1) % MR_PQ_MAX; - g_pq_n--; - snprintf(g_probe_inflight, sizeof g_probe_inflight, "%s", pj.path); - } else { - kind = 2; - tr = g_treq[g_treq_head]; - g_treq_head = (g_treq_head + 1) % MR_TREQ_MAX; - g_treq_n--; - snprintf(g_thumb_inflight, sizeof g_thumb_inflight, "%s", tr.cover); - } - pthread_mutex_unlock(&g_lock); - - if (kind == 1) { - mr_library_probe p; - int ok = (mr_library_probe_path(pj.path, pj.is_archive, - cbz_archive_pages, NULL, &p) == 0); - pthread_mutex_lock(&g_lock); - g_probe_inflight[0] = '\0'; - if (!g_worker_stop) probe_push_res_locked(&pj, ok, &p); - pthread_mutex_unlock(&g_lock); - } else { - meow_img *im = thumb_load(tr.cover); - pthread_mutex_lock(&g_lock); - g_thumb_inflight[0] = '\0'; - if (im && !g_worker_stop) thumb_push_res_locked(&tr, im); - else if (im) meow_img_free(im); - pthread_mutex_unlock(&g_lock); - } - } - return NULL; -} - -static void worker_start(void) -{ - if (g_worker_on) return; - g_worker_stop = 0; - if (pthread_create(&g_worker, NULL, lib_worker, NULL) == 0) g_worker_on = 1; -} - -static void worker_stop(void) -{ - if (!g_worker_on) return; - pthread_mutex_lock(&g_lock); - g_worker_stop = 1; - pthread_cond_broadcast(&g_cv); - pthread_mutex_unlock(&g_lock); - pthread_join(g_worker, NULL); - g_worker_on = 0; - - pthread_mutex_lock(&g_lock); - for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); - g_tres_n = 0; - g_pres_n = 0; - g_treq_n = 0; - g_treq_head = g_treq_tail = 0; - g_pq_n = 0; - g_pq_head = g_pq_tail = 0; - g_probe_inflight[0] = '\0'; - g_thumb_inflight[0] = '\0'; - pthread_mutex_unlock(&g_lock); -} - -/* 探测请求:只投递(有界队列 + 去重),不做 IO。 */ -static void probe_enqueue(int idx, const char *path, int is_archive) -{ - if (!g_worker_on || !path || !path[0]) return; - pthread_mutex_lock(&g_lock); - if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } - if (strcmp(g_probe_inflight, path) == 0) { pthread_mutex_unlock(&g_lock); return; } - for (int i = 0; i < g_pq_n; i++) { - int p = (g_pq_head + i) % MR_PQ_MAX; - if (strcmp(g_pq[p].path, path) == 0) { pthread_mutex_unlock(&g_lock); return; } - } - if (g_pq_n >= MR_PQ_MAX) { pthread_mutex_unlock(&g_lock); return; } - g_pq[g_pq_tail].gen = g_gen; - g_pq[g_pq_tail].idx = idx; - g_pq[g_pq_tail].is_archive = is_archive; - snprintf(g_pq[g_pq_tail].path, sizeof g_pq[g_pq_tail].path, "%s", path); - g_pq_tail = (g_pq_tail + 1) % MR_PQ_MAX; - g_pq_n++; - pthread_cond_signal(&g_cv); - pthread_mutex_unlock(&g_lock); -} - -/* 缩略图请求:bind 只投递,不阻塞首绘/滚动。 */ -static void thumb_enqueue(int idx, const char *cover) -{ - if (!g_worker_on || !cover || !cover[0]) return; - pthread_mutex_lock(&g_lock); - if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } - if (strcmp(g_thumb_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } - for (int i = 0; i < g_treq_n; i++) { - int p = (g_treq_head + i) % MR_TREQ_MAX; - if (strcmp(g_treq[p].cover, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } - } - if (g_treq_n >= MR_TREQ_MAX) { pthread_mutex_unlock(&g_lock); return; } - g_treq[g_treq_tail].gen = g_gen; - g_treq[g_treq_tail].idx = idx; - snprintf(g_treq[g_treq_tail].cover, sizeof g_treq[g_treq_tail].cover, "%s", cover); - g_treq_tail = (g_treq_tail + 1) % MR_TREQ_MAX; - g_treq_n++; - pthread_cond_signal(&g_cv); - pthread_mutex_unlock(&g_lock); -} - -/* ============================================================ */ -/* 缩略图内存缓存 */ -/* ============================================================ */ -static void thumbs_free(void) -{ - for (int i = 0; i < g_thumb_n; i++) meow_img_free(g_thumbs[i].img); - g_thumb_n = 0; -} - -static meow_img *thumb_find(int idx) -{ - for (int i = 0; i < g_thumb_n; i++) - if (g_thumbs[i].idx == idx) return g_thumbs[i].img; - return NULL; -} - -static int thumb_put(int idx, meow_img *im) -{ - if (g_thumb_n >= MR_THUMB_MAX) return 0; - g_thumbs[g_thumb_n].idx = idx; - g_thumbs[g_thumb_n].img = im; - g_thumb_n++; - return 1; -} - -/* ============================================================ */ -/* 行数据源(src2:每帧按需取可见行;零 IO,懒算请求只入队) */ +/* 行数据源(src2:每帧按需取可见行;零 IO、零格式化) */ /* ============================================================ */ static int lib_src(int idx, meow_list_item *item, void *ctx) { @@ -486,33 +73,26 @@ static int lib_src(int idx, meow_list_item *item, void *ctx) mr_library_row row; if (!mr_library_row_at(&g_lib, idx, &row)) return 0; + item->title = row.title; item->subtitle = row.subtitle; - item->enabled = row.disabled ? 0 : 1; - if (row.disabled) return 1; - - int i = row.index; - mr_library_entry *e = &g_lib.items[i]; - item->right = row.is_book ? MEOW_LIST_RIGHT_NONE : MEOW_LIST_RIGHT_ARROW; - - meow_img *im = thumb_find(i); - if (im) { - item->icon_kind = MEOW_LIST_ICON_IMG; - item->icon = im; + if (row.disabled) { + item->enabled = 0; + return 1; + } + item->enabled = 1; + if (row.kind == MR_LIB_KIND_DIR) { + item->icon_kind = MEOW_LIST_ICON_FOLDER; + item->right = MEOW_LIST_RIGHT_ARROW; /* 目录=可进入 */ } else { - item->icon_kind = row.is_book ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; - /* 文件夹册用缩略图源图;档案册没有图片文件,用档案路径(worker 解压首图)。 */ - const char *src = NULL; - if (e->cover[0]) src = e->cover; - else if (row.is_book && row.is_archive) src = e->path; - if (src) thumb_enqueue(i, src); + item->icon_kind = MEOW_LIST_ICON_FILE; /* 图片/档案册=文件符号 */ + item->right = MEOW_LIST_RIGHT_NONE; /* 点开即读,无子级 */ } - if (e->probe_state == MR_LIB_PENDING) probe_enqueue(i, e->path, e->is_archive); return 1; } /* ============================================================ */ -/* 布局 / 列表重建 */ +/* 布局 */ /* ============================================================ */ static int lib_top_y(void) { @@ -527,6 +107,12 @@ static void hint_place(void) g_hint_h = MR_HINT_H; } +static const char *empty_text_for_state(void) +{ + if (g_sd_missing) return "未检测到 SD 卡(插卡后点右上角刷新)"; + return "空文件夹"; +} + static void list_rebuild(void) { if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } @@ -539,33 +125,30 @@ static void list_rebuild(void) int rows = g_lib.count + (g_lib.truncated ? 1 : 0); g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, rows, - lib_src, NULL, MEOW_LIST_SEL_NONE); + lib_src, NULL, MEOW_LIST_SEL_SINGLE); if (!g_lv) return; meow_listview_set_item_renderer(g_lv, 2, 1, 1); meow_listview_set_item_tier(g_lv, 2); - /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: - * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ + /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式(与文件管理器一致)。 */ meow_listview_set_container(g_lv, 1); - meow_listview_set_empty_text(g_lv, "空目录(无可进入的子目录)"); + meow_listview_set_empty_text(g_lv, empty_text_for_state()); g_scroll = meow_scroll_wrap(g_lv, 1); - if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); + /* 不设 snap:文件管理器手感是自由滚动,逐行吸附会发黏。 */ } static void update_topbar(void) { if (!g_tb) return; const char *title = "图库"; - if (strcmp(g_cur_dir, "/") == 0) { - title = "文件系统 /"; - } else if (strcmp(g_cur_dir, g_root_dir) != 0) { + if (strcmp(g_cur_dir, g_root_dir) != 0) { const char *slash = strrchr(g_cur_dir, '/'); if (slash && slash[1]) title = slash + 1; } meow_topappbar_set_title(g_tb, title); - /* 顶栏「上级」是显式浏览动作(访问放开,可越过 TF 根上溯到 '/'); - * 左缘返回是系统返回语义,TF 根=退出 App(见 library_go_up)。到 '/' 才隐藏。 */ - meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, "/") != 0); + /* 根目录锁定:根=浏览天花板,顶栏不显示返回/上级。 */ + g_tb_back = (strcmp(g_cur_dir, g_root_dir) != 0); + meow_topappbar_set_back(g_tb, g_tb_back); } static void list_invalidate(void) @@ -602,24 +185,17 @@ static void library_draw_hint(void *ctx) meow_ui_text(x + 20, ty, g_hint, th->font_body, th->error); } -/* 目录切换:作废旧异步结果、释放内存缩略图、重建列表(新池强制重新取行)。 */ -static void reset_async(void) +/* ============================================================ */ +/* 目录加载 */ +/* ============================================================ */ +static void repaint_all(void) { - pthread_mutex_lock(&g_lock); - g_gen++; - g_pq_n = 0; - g_pq_head = g_pq_tail = 0; - g_treq_n = 0; - g_treq_head = g_treq_tail = 0; - g_pres_n = 0; - for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); - g_tres_n = 0; - g_probe_inflight[0] = '\0'; - g_thumb_inflight[0] = '\0'; - pthread_mutex_unlock(&g_lock); + if (g_ctrl.active) + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); } -/* 目录切换:成功 1;不可读/不存在 0(提示;首次加载时仍建空列表)。 */ +/* 切到 target(空/失败列表也会切);成功 1,目录不可读 0。 + * 根目录不可读 → SD 卡空态;子目录不可读 → 停留原目录 + 提示。 */ static int load_dir(const char *dir) { /* dir 可能指向 g_lib 里的行路径(点击进入子目录):先拷到本地, @@ -631,133 +207,96 @@ static int load_dir(const char *dir) } snprintf(target, sizeof target, "%s", dir); + int is_root = (strcmp(target, g_root_dir) == 0); + mr_library next; memset(&next, 0, sizeof next); - - uint64_t t0 = meow_ui_time_ms(); - int n = mr_library_scan_ex(&next, target, MR_LIB_MAX_DEPTH, - cbz_archive_pages, NULL); - uint64_t ms = meow_ui_time_ms() - t0; + int n = mr_library_scan(&next, target, MR_LIB_MAX_DEPTH); if (n < 0) { mr_library_free(&next); + if (is_root) { + /* 未插卡/挂载失败/根不是目录:友好空态 + 可重试,绝不弹错误。 */ + mr_library_free(&g_lib); + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); + g_sd_missing = 1; + list_rebuild(); + update_topbar(); + repaint_all(); + meow_log(MEOW_LOG_INFO, "library: storage root unavailable: %s (errno=%d %s)", + target, errno, strerror(errno)); + return 0; + } const char *slash = strrchr(target, '/'); const char *name = (slash && slash[1]) ? slash + 1 : target; char msg[200]; - snprintf(msg, sizeof msg, "无法读取目录:%s", name); + snprintf(msg, sizeof msg, "无法读取文件夹:%s", name); show_hint(msg); meow_log(MEOW_LOG_WARN, "library scan failed: %s (errno=%d %s)", target, errno, strerror(errno)); - /* 启动首帧根目录不可读:仍建一个空列表,页面可交互(返回/回根/上溯)。 */ - if (!g_lv) { - mr_library_free(&g_lib); - snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); - reset_async(); - thumbs_free(); - list_rebuild(); - update_topbar(); - } return 0; } - if (ms > 120) - meow_log(MEOW_LOG_WARN, "library scan slow: %s %llums (%d rows)", - target, (unsigned long long)ms, n); mr_library_free(&g_lib); g_lib = next; snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); + g_sd_missing = 0; - reset_async(); - thumbs_free(); list_rebuild(); update_topbar(); g_drag = 0; g_dragged = 0; - if (g_ctrl.active) - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + repaint_all(); return 1; } +/* 顶栏刷新:重扫当前目录(无 SD 卡时就地重试;列表由 load_dir 重建)。 */ +static void refresh_current(void) +{ + load_dir(g_cur_dir); +} + int library_at_root(void) { return strcmp(g_cur_dir, g_root_dir) == 0; } +/* 系统返回语义:逐级上移;到浏览根(TF 卡根)返回 0 → app.c 退出 App。 + * 根目录锁定:不存在越过 MR_ROOT_DIR 的导航。 */ int library_go_up(void) { - /* 系统返回语义:TF 根 = 退出 App(app.c 收到 0 后 request_exit), - * 不再上溯系统目录;文件系统 "/" 同理。 */ - if (strcmp(g_cur_dir, g_root_dir) == 0) return 0; - if (strcmp(g_cur_dir, "/") == 0) return 0; - return library_go_parent(); -} - -/* 顶栏「上级」:显式浏览动作,访问放开——可越过 TF 根一路到 '/'。 */ -int library_go_parent(void) -{ - if (strcmp(g_cur_dir, "/") == 0) return 0; + if (library_at_root()) return 0; char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; + /* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */ + size_t rl = strlen(g_root_dir); + if (strlen(parent) < rl || strncmp(parent, g_root_dir, rl) != 0) + snprintf(parent, sizeof parent, "%s", g_root_dir); if (!load_dir(parent)) { - /* 父目录不可读 → 提示并停留。特例:父目录是文件系统 "/" 时仍切到 "/" 的空列表, - * 让下一次返回=退出 App(Android 沙箱可能读不了 "/",否则会卡在最后一个可读目录)。 */ - if (strcmp(parent, "/") == 0) { - mr_library_free(&g_lib); - snprintf(g_cur_dir, sizeof g_cur_dir, "%s", parent); - reset_async(); - thumbs_free(); - list_rebuild(); - update_topbar(); - if (g_ctrl.active) - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); - } + /* 父目录不可读 → 提示并停留(不越根、不丢当前列表)。 */ return 1; } return 1; } -/* TopAppBar「TF卡」:回到书库根(已在根则回到列表顶部)。 */ -static void library_go_tf_root(void) -{ - if (strcmp(g_cur_dir, g_root_dir) == 0) { - if (g_scroll) meow_scroll_stop(g_scroll); - if (g_lv) meow_listview_set_scroll(g_lv, 0); - list_invalidate(); - return; - } - load_dir(g_root_dir); -} - -/* 点击行:先确保懒判定完成(单层探测,通常已在缓存里),书=进阅读器,否则进目录。 */ +/* 点击行:目录=进入;图片=从该图开始读;档案册=打开档案册。 */ static void library_open_entry(int idx) { if (idx < 0 || idx >= g_lib.count) return; - mr_library_entry *e = &g_lib.items[idx]; + const mr_library_entry *e = &g_lib.items[idx]; - if (e->probe_state == MR_LIB_PENDING) { - mr_library_probe p; - if (mr_library_probe_path(e->path, e->is_archive, cbz_archive_pages, NULL, &p) == 0) { - mr_library_apply_probe(&g_lib, idx, &p); - } else { - meow_log(MEOW_LOG_WARN, "library probe failed: %s (errno=%d %s)", e->path, - errno, strerror(errno)); - mr_library_mark_error(&g_lib, idx); - } - list_invalidate(); - } - if (e->probe_state == MR_LIB_FAILED) { - char msg[200]; - snprintf(msg, sizeof msg, "无法读取:%s", e->name); - show_hint(msg); + if (e->kind == MR_LIB_KIND_DIR) { + load_dir(e->path); return; } - if (e->has_images == 1) { mr_reader_open_dir(e->path); return; } - if (e->is_archive) { /* 档案册但没有图片页(坏档/空档):不导航 */ - char msg[200]; - snprintf(msg, sizeof msg, "无法阅读(档内没有图片页):%s", e->name); - show_hint(msg); + if (e->kind == MR_LIB_KIND_IMAGE) { + /* 下标由 logic 用与阅读器目录册相同的过滤器/比较器数出,保证「点 A 开 A」。 */ + int start = mr_library_image_index(&g_lib, idx); + mr_reader_open_dir_at(g_cur_dir, start < 0 ? 0 : start); return; } - load_dir(e->path); + if (e->kind == MR_LIB_KIND_ARCHIVE) { + mr_reader_open_dir_at(e->path, MR_READER_START_RESUME); + } } /* ============================================================ */ @@ -768,12 +307,16 @@ void library_on_tap(int x, int y) if (!g_ctrl.active) return; g_drag = 0; g_dragged = 0; + press_clear(); if (g_tb) { int hit = meow_topappbar_hit(g_tb, x, y); - if (hit == MEOW_TOPBAR_BACK) { library_go_parent(); return; } - if (g_tb_home_idx >= 0 && hit == g_tb_home_idx) { library_go_tf_root(); return; } - if (hit >= 0) { meow_view_page_push(ACT_SETTINGS); return; } + if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (g_act_refresh >= 0 && hit == g_act_refresh) { refresh_current(); return; } + if (g_act_settings >= 0 && hit == g_act_settings) { + meow_view_page_push(ACT_SETTINGS); + return; + } } if (!g_lv) return; int idx = meow_listview_hit(g_lv, x, y); @@ -811,6 +354,7 @@ void library_on_touch_down(int x, int y) * 否则系统返回手势的 move 仍会驱动列表滚动。 */ g_drag = 0; g_dragged = 0; + press_clear(); /* 屏底 28px 是系统「上滑回桌面」热区:不从这里启动列表拖拽,避免拦截系统手势。 */ if (y >= meow_ui_height() - 28) return; /* 左缘 24px 是系统返回手势带(与 reader 同口径):列表不启动拖拽,把滑动留给系统返回。 */ @@ -820,6 +364,9 @@ void library_on_touch_down(int x, int y) g_drag = 1; g_dragged = 0; g_drag_last_y = y; + /* 按下高亮(与文件管理器一致;拖动超阈值即清除)。 */ + g_press_row = meow_listview_hit(g_lv, x, y); + if (g_press_row >= 0) meow_listview_set_sel(g_lv, g_press_row); } void library_on_touch_move(int x, int y) @@ -829,61 +376,24 @@ void library_on_touch_move(int x, int y) int dy = g_drag_last_y - y; if (dy == 0) return; if (dy > 1 || dy < -1) g_dragged = 1; + if (g_press_row >= 0) { + g_press_delta += dy > 0 ? dy : -dy; + if (g_press_delta > 10) press_clear(); /* 拖动:清除按下高亮 */ + } g_drag_last_y = y; if (g_scroll) meow_scroll_track(g_scroll, dy, meow_ui_time_ms()); else meow_listview_scroll_by(g_lv, dy); list_invalidate(); } -/* 16ms tick:收下后台探测/缩略图结果并局部补位;提示横幅到期自动清除。 */ +/* 16ms tick:只剩提示横幅到期清除(列表同步扫描,无后台补位)。 */ void library_on_tick(void) { if (!g_ctrl.active) return; - if (g_hint[0] && meow_ui_time_ms() >= g_hint_until) { g_hint[0] = '\0'; hint_invalidate(); } - - probe_res pres[MR_PRES_MAX]; - int pn = 0; - pthread_mutex_lock(&g_lock); - pn = g_pres_n; - for (int i = 0; i < pn; i++) pres[i] = g_pres[i]; - g_pres_n = 0; - pthread_mutex_unlock(&g_lock); - - int changed = 0; - for (int i = 0; i < pn; i++) { - probe_res *r = &pres[i]; - if (r->gen != g_gen || r->idx < 0 || r->idx >= g_lib.count) continue; - mr_library_entry *e = &g_lib.items[r->idx]; - if (strcmp(e->path, r->path) != 0) continue; - if (r->ok) { - if (mr_library_apply_probe(&g_lib, r->idx, &r->probe) && e->cover[0]) - thumb_enqueue(r->idx, e->cover); - } else { - mr_library_mark_error(&g_lib, r->idx); - } - changed = 1; - } - - thumb_res got[MR_TRES_MAX]; - int n = 0; - pthread_mutex_lock(&g_lock); - n = g_tres_n; - for (int i = 0; i < n; i++) got[i] = g_tres[i]; - g_tres_n = 0; - pthread_mutex_unlock(&g_lock); - - for (int i = 0; i < n; i++) { - if (got[i].gen == g_gen && got[i].idx >= 0 && got[i].idx < g_lib.count && - !thumb_find(got[i].idx) && thumb_put(got[i].idx, got[i].img)) - changed = 1; - else - meow_img_free(got[i].img); - } - if (changed) list_invalidate(); } /* ============================================================ */ @@ -894,16 +404,14 @@ static void library_page_create(meow_view *v, void *ctx) mr_page_ctrl *c = (mr_page_ctrl *)ctx; c->view = v; - cache_init(); - worker_start(); - int w = meow_ui_width(); int sb = meow_ui_statusbar_height(); g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); if (g_tb) { - g_tb_home_idx = meow_topappbar_add_action(g_tb, "TF卡"); - int ai = meow_topappbar_add_action(g_tb, ""); - if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_SETTINGS); + g_act_refresh = meow_topappbar_add_action(g_tb, ""); + if (g_act_refresh >= 0) meow_topappbar_set_item_icon(g_tb, g_act_refresh, MEOW_ICON_REFRESH); + g_act_settings = meow_topappbar_add_action(g_tb, ""); + if (g_act_settings >= 0) meow_topappbar_set_item_icon(g_tb, g_act_settings, MEOW_ICON_SETTINGS); } hint_place(); @@ -936,7 +444,8 @@ static void library_page_enter(meow_view *v, void *ctx) hint_place(); g_drag = 0; g_dragged = 0; - worker_start(); /* 离开时停止(见 leave),回页恢复探测/缩略图 */ + /* 无 SD 卡空态:重进页面即重扫(用户要求的可重试路径之一)。 */ + if (g_sd_missing) load_dir(g_cur_dir); update_topbar(); mr_nav_report(); meow_ui_paint_full(); @@ -948,24 +457,19 @@ static void library_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; g_drag = 0; g_dragged = 0; + press_clear(); if (g_scroll) meow_scroll_stop(g_scroll); - /* 停止后台解码:进 reader 前释放/停止潜在的大图解码,避免与 open_book 的 - * opendir/malloc 抢内存(设备上「点开父文件夹=无法打开此册」的诱因之一)。 - * worker_stop 会 join,保证 page_push 的 on_enter 打开册时线程已退出。 */ - worker_stop(); } static void library_page_destroy(meow_view *v, void *ctx) { (void)v; (void)ctx; - worker_stop(); if (g_hint_id) { meow_draw_unregister(g_hint_id); g_hint_id = 0; } if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } - thumbs_free(); + g_act_refresh = g_act_settings = -1; mr_library_free(&g_lib); - mr_library_probe_cache_clear(); } meow_view *library_view_create(void) @@ -1031,10 +535,29 @@ const char *library_test_hint(void) return g_hint; } -int library_test_go_root(void) +/* 顶栏返回箭头是否显示(根目录必须隐藏)。 */ +int library_test_topbar_back(void) { - library_go_tf_root(); - return strcmp(g_cur_dir, g_root_dir) == 0; + return g_tb_back; +} + +/* 当前列表空态文案(无 SD 卡/空文件夹)。 */ +const char *library_test_empty_text(void) +{ + return empty_text_for_state(); +} + +/* 1=根目录不可访问(未检测到 SD 卡的空态)。 */ +int library_test_sd_missing(void) +{ + return g_sd_missing; +} + +/* 顶栏刷新动作:重扫当前目录(无 SD 卡时就地重试)。 */ +int library_test_retry(void) +{ + refresh_current(); + return library_test_rows(); } int library_test_go_up(void) @@ -1042,36 +565,9 @@ int library_test_go_up(void) return library_go_up(); } -int library_test_go_parent(void) -{ - return library_go_parent(); -} - /* 跟手拖拽是否在本次手势中被启动(左缘/屏底热区排除的回归观测口)。 */ int library_test_drag_active(void) { return g_drag; } - -/* 后台探测/缩略图线程是否在跑(离开书库页应停止,回页恢复)。 */ -int library_test_worker_on(void) -{ - return g_worker_on; -} - -/* 缩略图 LRU 接缝(M11 P2-1 回归):指向当前缓存目录并同步跑一次 prune。 */ -const char *library_test_cache_dir(void) -{ - return g_cache_dir; -} - -int library_test_cache_on(void) -{ - return g_cache_on; -} - -void library_test_cache_prune(void) -{ - cache_prune(); -} #endif diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 442c3e6..1fba5ef 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -2040,6 +2040,7 @@ static void reader_state_reset(void) static void reader_page_enter(meow_view *v, void *ctx) { mr_page_ctrl *c = (mr_page_ctrl *)ctx; + c->view = v; c->active = 1; g_quitting = 0; @@ -2162,6 +2163,14 @@ const char *mr_reader_dir(void) void mr_reader_open_dir_at(const char *path, int start_index) { if (!path) return; + /* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER): + * SDK 对同一 view 的重复 push 是 no-op(view_enter 见 ENTERED 直接返回), + * 只改 g_dir 会串到旧册状态。先真正 pop(回收当前册 + 续读落盘), + * 换目标后再 push,走完整的 leave/enter 生命周期。 */ + int reopen = (meow_ui_activity_active() == ACT_READER) && + (meow_ui_activity_depth() > 1); + if (reopen) meow_view_page_pop(); + snprintf(g_dir, sizeof g_dir, "%s", path); /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME From 4befbfe14c6afac066c4975a6a91036f5e52c107 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 20:04:41 +0800 Subject: [PATCH 20/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=B9=A6=E5=BA=93?= =?UTF-8?q?=E7=82=B9=E5=9B=BE=E6=94=B9=E6=8C=89=E6=96=87=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=88=E4=BF=AE=20P1=20=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E9=94=99=E4=BD=8D=EF=BC=89+=20=E5=86=8C=E9=97=B4=E8=A1=94?= =?UTF-8?q?=E6=8E=A5=E6=A0=B9=E9=94=81=E5=AE=9A=EF=BC=88M18=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20r1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1(会静默开错图):取消「书库从列表推导图片下标」。 - 新增 mr_reader_open_dir_at_name(dir, name):阅读器打开目录册后,在**自己刚枚举出的 全量目录册**(mr_dir_book,与翻页同一份枚举)里按 basename 定位起始页(先精确, 再大小写不敏感兜底;找不到退回续读/首页)。书库点击只传文件名。 - 删除 mr_library_image_index(不再有跨枚举的隐式契约)。 - 截断提示文案改为「仅显示前 2000 项(图片可在阅读器中翻到)」。 - 修复前(cb02722 探针,2100 张 4 位名夹具):rows=2000/truncated=1/ library_images=2000/reader_pages=2100/**mismatched=590**(首行 p1412 → 打开 p1411); 评审夹具为 1995/2000。修复后:2000 个可见图片行按名定位 **0 mismatch**, 2100 页全量可翻;26 次真实 UI 点开(首尾各 3 + 20 个确定性伪随机)逐张命中。 连带处理(评审 r1): 1. 删掉 reader_page_enter 的 c->view 死写(reopen 判定用 activity active/depth); 顺手删掉 library_page_create 里同款死写。 2. 阅读器册间衔接根锁定:新增 mr_path_within_root + mr_book_adjacent_rooted, book_switch 用浏览根(默认 MR_ROOT_DIR,测试可注入)钳制相邻册; 根外的兄弟册按「没有相邻册」处理。test_cont_adjacent A7 验证「明文 _ex 会越根、 rooted 拒绝、根内翻册不受影响」;test_mode_switch_host H6 验证条漫末页不越根。 3. test_reader_perf 不再依赖顶栏 tap 命中时序:模式切换改走测试钩子 mr_reader_test_toggle_mode()(与顶栏动作同一个 mode_set),负载下稳定。 4. page_cache 注释去掉「缩略图缓存/缩略图线程」过时描述(只改文字)。 回归:run_tests.sh(library 81 / nav 74 / lazy 57 / perf 25 / … / open_book 含 2100 用例) 与 run_webtoon_tests.sh(含 A7/H6)全 PASS;交叉构建零警告。 --- tuku/README.md | 4 +- tuku/logic/library.c | 41 +++++++--- tuku/logic/library.h | 17 +++-- tuku/logic/reader/page_cache.c | 2 +- tuku/logic/reader/page_cache.h | 13 ++-- tuku/test/run_tests.sh | 4 +- tuku/test/test_browse_lazy.c | 121 +++++++++++++++++++++++------- tuku/test/test_browse_perf.c | 2 - tuku/test/test_browse_ui_host.c | 28 ++++--- tuku/test/test_cont_adjacent.c | 82 +++++++++++++++++++- tuku/test/test_library.c | 41 +++++----- tuku/test/test_mode_switch_host.c | 32 +++++++- tuku/test/test_open_book_host.c | 113 ++++++++++++++++++++++++++++ tuku/test/test_reader_perf.c | 18 +++-- tuku/ui/library/library.c | 24 ++++-- tuku/ui/reader/reader.c | 80 +++++++++++++++++--- tuku/ui/reader/reader.h | 13 ++++ 17 files changed, 523 insertions(+), 112 deletions(-) diff --git a/tuku/README.md b/tuku/README.md index e3dc6a2..6cd3a95 100644 --- a/tuku/README.md +++ b/tuku/README.md @@ -11,8 +11,10 @@ - 列表 = 当前目录一层的**子目录 + 受支持图片(png/jpg/jpeg/bmp)+ 档案册(.cbz/.zip)**; 目录优先,组内自然序(大小写不敏感,同键按原始字节兜底,`A.jpg`/`a.jpg` 不抖动)。 -- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(下标与阅读器目录册同一套过滤器/比较器); +- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(按**文件名**交给阅读器, + 由阅读器的全量目录册定位起始页——列表被 `MR_LIB_SCAN_MAX` 截断时也不会点错图); 点档案册 = 打开档案册(第 1 页/续读)。 +- 单目录超过 2000 项时列表只显示前 2000 项并给出「已截断」提示行;图片仍可进入阅读器翻到。 - 缩略图功能已关闭:行内只有类型图标 + 文件名 + 类型·大小;首帧只做单层 `opendir/readdir + lstat`,滚动/点击路径不解码、不访问文件、不重排字符串。 - 浏览根锁定 TF 卡根(`/usr/data/mnt/sd_0`):根目录不显示返回箭头,左缘返回 = 退出 App; diff --git a/tuku/logic/library.c b/tuku/logic/library.c index be142d7..a8582fc 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -257,13 +257,16 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) { - static char trunc_sub[48]; + static char trunc_sub[96]; static int trunc_ready; if (!lib || !out) return 0; if (idx == lib->count && lib->truncated) { if (!trunc_ready) { - snprintf(trunc_sub, sizeof trunc_sub, "仅显示前 %d 项", MR_LIB_SCAN_MAX); + /* 截断只是列表显示上限:图片仍能从任一可见图片进入阅读器后翻到, + * 文案不要让人误以为「没有更多图片」。 */ + snprintf(trunc_sub, sizeof trunc_sub, + "仅显示前 %d 项(图片可在阅读器中翻到)", MR_LIB_SCAN_MAX); trunc_ready = 1; } memset(out, 0, sizeof *out); @@ -284,14 +287,15 @@ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) return 1; } -int mr_library_image_index(const mr_library *lib, int idx) +int mr_path_within_root(const char *path, const char *root) { - if (!lib || idx < 0 || idx >= lib->count) return -1; - if (lib->items[idx].kind != MR_LIB_KIND_IMAGE) return -1; - int n = 0; - for (int i = 0; i < idx; i++) - if (lib->items[i].kind == MR_LIB_KIND_IMAGE) n++; - return n; + if (!path || !path[0] || !root || !root[0]) return 0; + size_t rl = strlen(root); + while (rl > 1 && root[rl - 1] == '/') rl--; + if (rl == 1 && root[0] == '/') return path[0] == '/'; + if (strncmp(path, root, rl) != 0) return 0; + char c = path[rl]; + return c == '\0' || c == '/'; } int mr_path_parent(const char *path, char *out, int outsz) @@ -433,6 +437,25 @@ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, return rc; } +int mr_book_adjacent_rooted(const char *dir, int delta, const char *root, + mr_lib_archive_fn archive_pages, void *ud, + char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || !root || !root[0]) return -1; + /* 只允许在浏览根内翻册:当前册或相邻册越根一律当「没有相邻册」。 */ + if (!mr_path_within_root(dir, root)) return 0; + + int r = mr_book_adjacent_ex(dir, delta, out, outsz, archive_pages, ud); + if (r != 1) return r; + if (!mr_path_within_root(out, root)) { + out[0] = '\0'; + return 0; + } + return 1; +} + int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) { return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL); diff --git a/tuku/logic/library.h b/tuku/logic/library.h index a4fd7d0..b51966f 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -86,12 +86,9 @@ int mr_path_parent(const char *path, char *out, int outsz); * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); -/* 图片行在该目录图片自然序中的下标(0 基);idx 不是图片行/越界返回 -1。 - * - * ⚠️ 这是书库→阅读器的契约:阅读器的目录册(logic/reader/page_source.c)只枚举 - * 「非隐藏 + mr_path_is_image + 普通文件」并按 mr_natural_cmp 排序。书库扫描用的是 - * 同一套过滤器与比较器,因此本函数数出的下标就是 mr_reader_open_dir_at() 的起始页。 */ -int mr_library_image_index(const mr_library *lib, int idx); +/* path 是否落在浏览根内(等于 root 或以 "root/" 开头)。1=在内;0=越界/参数非法。 + * 根锁定用:书库导航与阅读器「相邻册」都不得越过 SD 卡根。 */ +int mr_path_within_root(const char *path, const char *root); /* 目录是否**直接**含受支持图片。1=有;0=没有/不可读。 * 仅用于阅读器的「相邻册」判定,不再是书库行语义。 */ @@ -111,6 +108,14 @@ typedef int (*mr_lib_archive_fn)(const char *path, void *ud); int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); +/* 册间衔接(根锁定版):在 mr_book_adjacent_ex 之上拒绝浏览根之外的相邻册。 + * dir 必须在 root 内;找到的相邻册也必须在 root 内,否则按「没有相邻册」返回 0。 + * 1=找到并写入 out;0=没有相邻册或越根被拒(out 为空串);-1=参数非法。 + * 阅读器用它保证「读到末页翻册」不会跳到 SD 卡根之外(例如挂载点以上的其它目录)。 */ +int mr_book_adjacent_rooted(const char *dir, int delta, const char *root, + mr_lib_archive_fn archive_pages, void *ud, + char *out, int outsz); + #ifdef MR_LIBRARY_TEST_HOOKS /* 宿主测试用 IO 计数(scan/dir_has_images 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ typedef struct mr_library_io_stats { diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c index 4cf403b..9c813ce 100644 --- a/tuku/logic/reader/page_cache.c +++ b/tuku/logic/reader/page_cache.c @@ -323,7 +323,7 @@ int mr_cache_store(const char *path, const void *data, size_t len) int i = idx_find_locked(path); if (i >= 0) idx_remove_locked(i); make_room_locked((long long)len); - pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞缩略图线程 */ + pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞并发读者 */ FILE *f = fopen(tmp, "wb"); int ok = f && fwrite(data, 1, len, f) == len; diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h index e0c4bda..96e5171 100644 --- a/tuku/logic/reader/page_cache.h +++ b/tuku/logic/reader/page_cache.h @@ -5,21 +5,20 @@ * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 * * 目录与上限(纪律): - * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data, - * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data); + * 文件名统一前缀 mrpage_,与其它可能落在这目录的临时文件互不影响; * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); * - **单条目溢出**:某页解压后本身就超过 32MiB 时允许一次性溢出——只保留该条、 * 其它条目全部淘汰,占用上限受页源单页上限 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束。 * 这样超大页仍可阅读,不会因为预算拒绝落缓存而变成永久空白页; - * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页) - * (注意:缩略图 LRU 也必须在同一目录里跳过 mrpage_*,否则会互删); + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页); * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 * * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 * 同名条目重复出现时不会命中旧内容。文件名保留图片扩展名(解码入口按扩展名区分 * JPEG 的「解码期缩解」路径)。 * - * 线程:内部互斥量;可在解码线程/缩略图线程/主线程并发调用。 + * 线程:内部互斥量;可在解码线程/主线程并发调用。 * 纯 C:宿主测试可直接编译(见 tuku/test/test_cbz.c)。 */ #ifndef MR_PAGE_CACHE_H @@ -42,8 +41,8 @@ extern "C" { * 成功 0;参数非法/缓冲不足 -1。 */ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); -/* 文件名是否属于解压页缓存(mrpage_ 前缀)。缩略图 LRU 与启动清孤儿共用这一判据, - * 保证两套缓存同目录时互不误删(P2-1)。1=解压页缓存。 */ +/* 文件名是否属于解压页缓存(mrpage_ 前缀)。启动清孤儿用这一判据, + * 保证同目录的其它文件不被误删。1=解压页缓存。 */ int mr_cache_is_page_name(const char *name); /* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index dbdde77..2c35f8a 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,8 +1,8 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/图片下标/父目录/阅读方向 +# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/父目录/根前缀判据/阅读方向 # - test_browse_nav:访问放开(系统/空/无图目录可进入)、图片与档案册成行、截断、不可读 -# - test_browse_lazy:行合成零 IO/副标题预生成/图片下标 == 阅读器目录册下标(集成陷阱) +# - test_browse_lazy:行合成零 IO/副标题预生成/书库行与阅读器目录册同序(按名定位契约) # - test_browse_perf:500 目录×8 层嵌套 + 2000 项大目录,断言单层扫描与行合成成本 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c index 731048a..3441527 100644 --- a/tuku/test/test_browse_lazy.c +++ b/tuku/test/test_browse_lazy.c @@ -1,12 +1,15 @@ -/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 图片下标一致性(纯 C,无 SDK)。 +/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 书库行与阅读器目录册同序(纯 C,无 SDK)。 * * 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖: * L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; - * L2 **书库→阅读器下标记号契约**:mr_library_image_index 数出的下标 == 阅读器目录册 - * (logic/reader/page_source.c: mr_dir_book_init)里同一张图的下标。 + * L2 **书库行与阅读器目录册同源**:书库图片行的名称序列 == 阅读器目录册 + * (logic/reader/page_source.c: mr_dir_book_init)的页名序列(同过滤器/同比较器)。 + * 点图现在由阅读器按文件名自己定位下标,书库不再推导下标;本用例守住「两边枚举一致」。 * 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、 * 前导零(page01/page1)、夹在图片之间的档案册与普通文件; - * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化)。 + * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化); + * L4 截断安全(评审 P1-1):2100 张图(默认 cap=2000)时,2000 个可见图片行按文件名 + * 都能定位到同一文件(0 mismatch),被截掉的图仍在阅读器页表里可翻到。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ @@ -137,8 +140,8 @@ static void test_rows_zero_io(const char *root) mr_library_free(&lib); } -/* ---- L2:书库图片下标 == 阅读器目录册下标 ---- */ -static void test_image_index_matches_reader(const char *dir) +/* ---- L2:书库图片行名称序列 == 阅读器目录册页名序列 ---- */ +static void test_library_rows_match_reader(const char *dir) { mr_library lib; memset(&lib, 0, sizeof lib); @@ -149,25 +152,15 @@ static void test_image_index_matches_reader(const char *dir) int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL); CHECK(pages > 0, "reader dir book pages=%d", pages); - /* 书库里的图片行按出现顺序,其下标必须依次是 0..n-1(无空洞/无重排)。 */ - int expect = 0; - for (int i = 0; i < lib.count; i++) { - if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; - int idx = mr_library_image_index(&lib, i); - CHECK(idx == expect, "image row %s index=%d (want %d)", - lib.items[i].name, idx, expect); - expect++; - } - CHECK(expect == pages, "library images=%d vs reader pages=%d", expect, pages); - if (expect != pages) { - mr_library_free(&lib); - mr_dir_book_free(&db); - return; - } + int images = 0; + for (int i = 0; i < lib.count; i++) + if (lib.items[i].kind == MR_LIB_KIND_IMAGE) images++; + CHECK(images == pages, "library images=%d vs reader pages=%d", images, pages); - /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(下标即起始页)。 */ + /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(同过滤器/同自然序)。 + * 点图时阅读器按文件名重新定位下标,所以「名字集合 + 顺序」一致就是契约。 */ int k = 0; - for (int i = 0; i < lib.count; i++) { + for (int i = 0; i < lib.count && k < pages; i++) { if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; char page_path[MR_PATH_MAX]; mr_dir_book_path(&db, k, page_path, (int)sizeof page_path); @@ -176,17 +169,90 @@ static void test_image_index_matches_reader(const char *dir) k++; } - /* 档案册/目录不占图片下标。 */ + /* 目录/档案册不是页:它们的名字不能出现在阅读器页名里。 */ for (int i = 0; i < lib.count; i++) { if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue; - CHECK(mr_library_image_index(&lib, i) == -1, - "non-image %s has no image index", lib.items[i].name); + int found = 0; + for (int j = 0; j < pages; j++) + if (db.names[j] && strcmp(db.names[j], lib.items[i].name) == 0) found = 1; + CHECK(found == 0, "non-image %s is not a reader page", lib.items[i].name); } mr_dir_book_free(&db); mr_library_free(&lib); } +/* ---- L4:2100 张图(默认 cap)——列表截断但按名定位 2000/2000 不错位 ---- */ +static void test_cap_name_lookup(const char *root) +{ + char big[MR_PATH_MAX], p[MR_PATH_MAX]; + joinp(big, sizeof big, root, "big"); + CHECK(mkdir_p(big) == 0, "mkdir big"); + for (int i = 1; i <= 2100; i++) { + snprintf(p, sizeof p, "%s/p%04d.jpg", big, i); + if (touch_file(p) != 0) { CHECK(0, "big image %s", p); return; } + } + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, big, MR_LIB_MAX_DEPTH); + CHECK(n == MR_LIB_SCAN_MAX, "big dir lists cap rows (%d)", n); + CHECK(lib.truncated == 1 && lib.count == MR_LIB_SCAN_MAX, "big dir truncated at cap"); + + mr_dir_book db; + memset(&db, 0, sizeof db); + CHECK(mr_dir_book_init(&db, big, 720, fake_load, fake_unload, NULL) == 2100, + "reader enumerates all 2100 pages"); + + /* 书库不再推导下标;点击传文件名。这里验证 2000 个可见图片行按名定位 + * 都能命中**同一个文件**(0 mismatch),被截掉的 100 张也仍在页表里。 */ + int checked = 0, mismatch = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + checked++; + int idx = -1; + for (int j = 0; j < db.count; j++) + if (db.names[j] && strcmp(db.names[j], lib.items[i].name) == 0) { idx = j; break; } + char page[MR_PATH_MAX]; + mr_dir_book_path(&db, idx, page, (int)sizeof page); + if (idx < 0 || strcmp(page, lib.items[i].path) != 0) mismatch++; + } + CHECK(checked == MR_LIB_SCAN_MAX, "checked %d library image rows", checked); + CHECK(mismatch == 0, "name lookup mismatch=%d (want 0)", mismatch); + + /* 被截掉的图(列表外的那 100 张)在阅读器页表里仍存在(可翻到)。 + * 截断发生在 readdir 顺序(非自然序),不假定是哪 100 张,按集合差验证。 */ + int missing = 0, missing_idx = -1; + char missing_name[MR_NAME_MAX] = ""; + for (int j = 0; j < db.count; j++) { + int in_list = 0; + for (int i = 0; i < lib.count; i++) + if (strcmp(lib.items[i].name, db.names[j]) == 0) { in_list = 1; break; } + if (!in_list) { + if (missing == 0) { + missing_idx = j; + snprintf(missing_name, sizeof missing_name, "%s", db.names[j]); + } + missing++; + } + } + CHECK(missing == 2100 - MR_LIB_SCAN_MAX, "truncated-out images=%d (want 100)", missing); + char want_path[MR_PATH_MAX], page[MR_PATH_MAX]; + snprintf(want_path, sizeof want_path, "%s/%s", big, missing_name); + mr_dir_book_path(&db, missing_idx, page, (int)sizeof page); + CHECK(missing_idx >= 0 && strcmp(page, want_path) == 0, + "truncated-out %s is still reader page %d", missing_name, missing_idx); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1 && + strstr(row.subtitle, "2000") && strstr(row.subtitle, "阅读器"), + "truncation row keeps images discoverable: %s", + row.subtitle ? row.subtitle : ""); + + mr_dir_book_free(&db); + mr_library_free(&lib); +} + int main(void) { char tmpl[] = "./mr_rows_XXXXXX"; @@ -219,7 +285,8 @@ int main(void) test_rows_zero_io(root); joinp(p, sizeof p, root, "mixed"); - test_image_index_matches_reader(p); + test_library_rows_match_reader(p); + test_cap_name_lookup(root); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c index 6c52e6b..25236a3 100644 --- a/tuku/test/test_browse_perf.c +++ b/tuku/test/test_browse_perf.c @@ -165,8 +165,6 @@ int main(void) CHECK(strcmp(biglib.items[PERF_BIG - 1].name, "img1999.png") == 0, "last=%s", biglib.items[PERF_BIG - 1].name); CHECK(biglib.items[PERF_BIG - 1].kind == MR_LIB_KIND_IMAGE, "last is image"); - CHECK(mr_library_image_index(&biglib, PERF_BIG - 1) == PERF_BIG - 1, - "image index at end"); } printf(" big dir scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", big_us, bn, st.opendirs, st.lstats); diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index 57f64e4..cf56ba6 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -5,7 +5,8 @@ * 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分; * U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问); * U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读 - * (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册; + * (mr_reader_open_dir_at_name(当前目录, 文件名);阅读器自己定位下标); + * 点档案册=打开档案册; * U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出); * 子目录返回=回根;不存在越过浏览根的导航; * U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」, @@ -47,18 +48,28 @@ int library_test_go_up(void); int library_test_drag_active(void); int library_test_has_list(void); -/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */ +/* 阅读器入口打桩:记录被打开的册路径 / 起始页 / 按名定位的文件名(本测试不建 reader 页)。 */ #define MR_READER_START_RESUME (-1) void mr_reader_open_dir_at(const char *dir, int start_index); +void mr_reader_open_dir_at_name(const char *dir, const char *name); static char g_opened[MR_PATH_MAX]; +static char g_opened_name[MR_NAME_MAX]; static int g_opened_start; static int g_opened_n; void mr_reader_open_dir_at(const char *dir, int start_index) { snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); + g_opened_name[0] = '\0'; g_opened_start = start_index; g_opened_n++; } +void mr_reader_open_dir_at_name(const char *dir, const char *name) +{ + snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); + snprintf(g_opened_name, sizeof g_opened_name, "%s", name ? name : ""); + g_opened_start = MR_READER_START_RESUME; + g_opened_n++; +} static int g_fail; #define CHECK(cond, ...) do { \ @@ -279,22 +290,17 @@ int main(void) CHECK(library_test_load(pics_dir) == 1, "entered Pictures"); CHECK(strcmp(library_test_dir(), pics_dir) == 0, "current dir=%s", library_test_dir()); - /* 图片下标 = 该目录里排在该图之前的图片行数(阅读器目录册同序)。 */ + /* 点图片:把**文件名**交给阅读器(阅读器用自己的全量目录册定位起始页; + * 书库列表可能被 MR_LIB_SCAN_MAX 截断,所以不再传下标)。 */ const char *target = "img10.png"; int ti = row_index_for(target); CHECK(ti >= 0, "target image row present"); - int expect_idx = 0; - for (int i = 0; i < ti; i++) { - meow_list_item tmp; - if (!library_test_src(i, &tmp)) continue; - if (tmp.subtitle && strncmp(tmp.subtitle, "图片 · ", strlen("图片 · ")) == 0) - expect_idx++; - } g_opened_n = 0; tap_row(target); CHECK(g_opened_n == 1 && strcmp(g_opened, pics_dir) == 0, "tap image opens reader for current dir (%s)", g_opened); - CHECK(g_opened_start == expect_idx, "start index=%d (want %d)", g_opened_start, expect_idx); + CHECK(strcmp(g_opened_name, target) == 0, "reader gets file name (%s)", g_opened_name); + CHECK(g_opened_start == MR_READER_START_RESUME, "name-based open does not pass an index"); CHECK(strcmp(library_test_dir(), pics_dir) == 0, "tap image does not navigate"); g_opened_n = 0; diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index 170c7d5..cf2392a 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -7,7 +7,9 @@ * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; * A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成, - * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复)。 + * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复); + * A7 册间衔接根锁定(M18 评审 P2-1):相邻册越过浏览根时按「没有相邻册」处理, + * 根内正常翻册不受影响(mr_book_adjacent_rooted)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ @@ -160,7 +162,7 @@ static void test_adjacent(const char *root) } /* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标); - * 目录优先;当前层的图片行带图片下标(点图 → reader 起始页)。 */ + * 目录优先;图片/档案册按类型成行。 */ static void test_library_rows(const char *root) { mr_test_cfg_clear(); @@ -191,7 +193,6 @@ static void test_library_rows(const char *root) if (strcmp(e->name, "loose.png") == 0) { seen_img = 1; CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row"); - CHECK(mr_library_image_index(&lib, i) == 0, "loose.png is image #0"); } } CHECK(seen1 == 1, "vol1 listed"); @@ -199,6 +200,80 @@ static void test_library_rows(const char *root) mr_library_free(&lib); } +/* A7 册间衔接的根锁定:相邻册必须在浏览根内,否则按「没有相邻册」处理。 + * 夹具:base/{aaa_outside, root}/{...};root 自己也是「册」(直接含图), + * 明文 _ex 会给出 root 的兄弟 aaa_outside,rooted 必须拒绝(不越 SD 卡根)。 */ +static void test_rooted_adjacent(void) +{ + char tmpl[] = "./mr_rooted_XXXXXX"; + char *base = mkdtemp(tmpl); + CHECK(base != NULL, "mkdtemp rooted fixture"); + if (!base) return; + + char root[MR_PATH_MAX], outside[MR_PATH_MAX], in1[MR_PATH_MAX], in2[MR_PATH_MAX]; + joinp(root, sizeof root, base, "root"); + joinp(outside, sizeof outside, base, "aaa_outside"); + joinp(in1, sizeof in1, root, "aaa_inside1"); + joinp(in2, sizeof in2, root, "aaa_inside2"); + CHECK(mkdir_p(outside) == 0 && mkdir_p(in1) == 0 && mkdir_p(in2) == 0, + "mkdir rooted fixture"); + + char p[MR_PATH_MAX]; + joinp(p, sizeof p, root, "000.png"); CHECK(touch_file(p) == 0, "root image"); + joinp(p, sizeof p, outside, "001.png"); CHECK(touch_file(p) == 0, "outside image"); + joinp(p, sizeof p, in1, "001.png"); CHECK(touch_file(p) == 0, "inside1 image"); + joinp(p, sizeof p, in2, "001.png"); CHECK(touch_file(p) == 0, "inside2 image"); + + char out[MR_PATH_MAX]; + + /* 明文衔接:root 的兄弟 aaa_outside 在浏览根之外,_ex 会返回它。 */ + memset(out, 0, sizeof out); + CHECK(mr_book_adjacent_ex(root, -1, out, (int)sizeof out, NULL, NULL) == 1 && + strcmp(out, outside) == 0, + "plain adjacency reaches outside root (%s)", out); + + /* 根锁定版:同样的调用必须拒绝越根。 */ + memset(out, 0x7f, sizeof out); + out[0] = '\0'; + CHECK(mr_book_adjacent_rooted(root, -1, root, NULL, NULL, + out, (int)sizeof out) == 0 && out[0] == '\0', + "rooted adjacency refuses book outside root (%s)", out); + CHECK(mr_book_adjacent_rooted(root, 1, root, NULL, NULL, + out, (int)sizeof out) == 0 && out[0] == '\0', + "rooted adjacency: no next inside root"); + + /* 根内正常翻册仍然可用(不能被误锁死)。 */ + memset(out, 0, sizeof out); + CHECK(mr_book_adjacent_rooted(in1, 1, root, NULL, NULL, + out, (int)sizeof out) == 1 && + strcmp(out, in2) == 0, "rooted next inside root (%s)", out); + CHECK(mr_book_adjacent_rooted(in2, -1, root, NULL, NULL, + out, (int)sizeof out) == 1 && + strcmp(out, in1) == 0, "rooted prev inside root (%s)", out); + CHECK(mr_book_adjacent_rooted(in1, -1, root, NULL, NULL, + out, (int)sizeof out) == 0, + "rooted: no book before inside1 (outside is out of root)"); + CHECK(mr_book_adjacent_rooted(in2, 1, root, NULL, NULL, + out, (int)sizeof out) == 0, + "rooted: no book after inside2"); + + /* 当前册本身在根外 → 视为没有相邻册。 */ + CHECK(mr_book_adjacent_rooted(outside, 1, root, NULL, NULL, + out, (int)sizeof out) == 0, "current dir outside root"); + + /* 参数边界。 */ + CHECK(mr_book_adjacent_rooted(root, 1, NULL, NULL, NULL, + out, (int)sizeof out) == -1, "rooted NULL root"); + CHECK(mr_book_adjacent_rooted(NULL, 1, root, NULL, NULL, + out, (int)sizeof out) == -1, "rooted NULL dir"); + CHECK(mr_book_adjacent_rooted(root, 1, root, NULL, NULL, NULL, 32) == -1, + "rooted NULL out"); + CHECK(mr_book_adjacent_rooted(root, 1, "", NULL, NULL, + out, (int)sizeof out) == -1, "rooted empty root"); + + rm_rf(base); +} + int main(void) { char tmpl[] = "./mr_cont_adj_XXXXXX"; @@ -240,6 +315,7 @@ int main(void) test_has_images(root); test_adjacent(root); test_library_rows(root); + test_rooted_adjacent(); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 1fcb9a3..bf04cc9 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -5,7 +5,7 @@ * T2 扩展名过滤:图片 png/jpg/jpeg/bmp 与档案册 cbz/zip 大小写不敏感;gif/txt 不支持; * T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序); * 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定); - * 图片行下标(mr_library_image_index)与行类型;不可读路径 -> -1;max_depth 忽略; + * 行类型;不可读路径 -> -1;max_depth 忽略;浏览根前缀判据 mr_path_within_root; * T4 阅读方向状态(logic/reader.c)。 * * 编译(在 tuku/ 下): @@ -80,13 +80,6 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } -static int find_index(const mr_library *lib, const char *name) -{ - for (int i = 0; i < lib->count; i++) - if (strcmp(lib->items[i].name, name) == 0) return i; - return -1; -} - static void test_natural(void) { CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); @@ -214,17 +207,27 @@ static void test_scan(void) CHECK(find_entry(&lib, "a.png") == NULL, "no recursion into subdirs"); CHECK(find_entry(&lib, "Deeper") == NULL, "no recursion into subdirs (deeper)"); - /* 图片下标 = 本层图片自然序(书库点图 → 阅读器起始页的契约)。 */ - int i_cover = find_index(&lib, "cover.JPG"); - int i_root = find_index(&lib, "root.png"); - CHECK(mr_library_image_index(&lib, i_cover) == 0, "cover.JPG is image #0"); - CHECK(mr_library_image_index(&lib, i_root) == 1, "root.png is image #1"); - CHECK(mr_library_image_index(&lib, find_index(&lib, "book.cbz")) == -1, - "archive has no image index"); - CHECK(mr_library_image_index(&lib, find_index(&lib, "Empty")) == -1, - "dir has no image index"); - CHECK(mr_library_image_index(&lib, -1) == -1 && mr_library_image_index(&lib, 99) == -1, - "out-of-range image index"); + /* 浏览根前缀判据(书库导航/阅读器册间衔接的根锁定;纯路径工具)。 */ + CHECK(mr_path_within_root("/usr/data/mnt/sd_0/a/b", "/usr/data/mnt/sd_0") == 1, + "nested path inside root"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0", "/usr/data/mnt/sd_0") == 1, + "root itself is inside"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0/", "/usr/data/mnt/sd_0") == 1, + "root with trailing slash is inside"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0evil", "/usr/data/mnt/sd_0") == 0, + "prefix-lookalike rejected"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_00", "/usr/data/mnt/sd_0") == 0, + "prefix-lookalike with digits rejected"); + CHECK(mr_path_within_root("/usr/data/mnt", "/usr/data/mnt/sd_0") == 0, + "parent of root rejected"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0x/a", "/usr/data/mnt/sd_0") == 0, + "sibling-prefixed path rejected"); + CHECK(mr_path_within_root("/a/b", "/a/") == 1, "root trailing slash tolerated"); + CHECK(mr_path_within_root("/a", "/") == 1, "filesystem root accepts abs path"); + CHECK(mr_path_within_root("/", "/") == 1, "filesystem root itself"); + CHECK(mr_path_within_root("rel/path", "/") == 0, "relative path not inside /"); + CHECK(mr_path_within_root(NULL, "/") == 0 && mr_path_within_root("/a", NULL) == 0 && + mr_path_within_root("/a", "") == 0, "NULL/empty safe"); /* 行合成:纯内存,返回扫描期生成的字符串。 */ mr_library_row row; diff --git a/tuku/test/test_mode_switch_host.c b/tuku/test/test_mode_switch_host.c index 1a7d9d8..a77aa1d 100644 --- a/tuku/test/test_mode_switch_host.c +++ b/tuku/test/test_mode_switch_host.c @@ -8,7 +8,8 @@ * H3 跳页滑块:展开 → 拖到末页 → 目标页解码完成后落位(不是先白屏再跳); * H4 离开/重进:恢复上次页 + 模式(续读); * H5 册间衔接:条漫末页上拉 → 下一册首页;首页下拉 → 上一册末页;首册再无上一册时 - * 停在原地(不退出、不崩)。 + * 停在原地(不退出、不崩); + * H6 册间衔接根锁定:浏览根内没有相邻册时,不跨到根外的兄弟册(M18 评审观察项)。 * * 编译:test/run_webtoon_tests.sh(需 SDK 源码树;只发布 meow_ui.a 时自动 SKIP)。 */ @@ -164,6 +165,10 @@ int main(void) meow_view *reader = reader_view_create(); meow_view_page_push(ACT_LIBRARY); + /* 阅读器册间衔接的浏览根:测试夹具根(生产默认 MR_ROOT_DIR)。 */ + mr_reader_test_set_root(root); + CHECK(strcmp(mr_reader_test_root(), root) == 0, "reader browse root installed"); + /* TopAppBar 动作格(位图):右起每格 48px;动作 0=模式 / 1=跳页 / 2=fit。 */ int w = meow_ui_width(); int mode_x = w - 10 - 48 * 3 + 24; @@ -258,6 +263,31 @@ int main(void) CHECK(strcmp(mr_reader_dir(), book1) == 0, "no previous book: stay put"); CHECK(meow_ui_activity_active() == ACT_READER, "no previous book: still in reader (no exit)"); + /* ---- H6 册间衔接根锁定:浏览根=book2 时,兄弟册 book1 在根外,不得翻过去 ---- */ + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library for root-lock case"); + mr_reader_test_set_root(book2); + CHECK(strcmp(mr_reader_test_root(), book2) == 0, "reader root narrowed to book2"); + mr_reader_open_dir_at(book2, 0); /* 显式首页,避开续读页差异 */ + CHECK(meow_ui_activity_active() == ACT_READER, "reopened book2"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, "at book2"); + if (mr_reader_ui_mode() != MR_MODE_WEBTOON) mr_reader_test_toggle_mode(); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon for root-lock case"); + CHECK(mr_reader_ui_page() == 0, "root-lock case at first page (got %d)", + mr_reader_ui_page()); + + /* 首页下拉 → book1 是 book2 的上一册,但在浏览根外:必须原地不动。 */ + reader_on_touch_down(240, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 300 + i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "root-lock down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, + "adjacent book outside browse root is refused (dir=%s)", mr_reader_dir()); + CHECK(meow_ui_activity_active() == ACT_READER, "root-lock: still in reader"); + mr_reader_test_set_root(root); /* 复原夹具根 */ + /* ---- 收尾 ---- */ meow_view_page_pop(); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "popped back to library"); diff --git a/tuku/test/test_open_book_host.c b/tuku/test/test_open_book_host.c index dc3a503..f9f82fa 100644 --- a/tuku/test/test_open_book_host.c +++ b/tuku/test/test_open_book_host.c @@ -14,6 +14,7 @@ */ #include "tuku_internal.h" #include "ui/reader/reader.h" +#include "logic/reader/page_source.h" #include #include @@ -28,12 +29,14 @@ void library_test_set_root(const char *root); const char *library_test_dir(void); int library_test_rows(void); int library_test_src(int idx, meow_list_item *item); +int library_test_open_row(int idx); /* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */ int mr_reader_ui_mode(void); int mr_reader_ui_page(void); int mr_reader_ui_count(void); int mr_reader_ui_open_err(void); +void mr_reader_open_dir_at_name(const char *dir, const char *name); static int g_fail; #define CHECK(cond, ...) do { \ @@ -152,6 +155,22 @@ static int expected_image_index(const char *name) return n; } +/* 阅读器目录册用的假解码回调(本测试不经过它们取页)。 */ +static meow_img *cap_fake_load(const char *path, void *ud, int maxdim) +{ + (void)path; (void)ud; (void)maxdim; + return NULL; +} +static void cap_fake_unload(meow_img *img) { (void)img; } + +/* 在阅读器的全量目录册里按文件名取页下标(与阅读器打开时同一份枚举)。 */ +static int reader_page_of_name(const mr_dir_book *db, const char *name) +{ + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcmp(db->names[i], name) == 0) return i; + return -1; +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -184,6 +203,16 @@ int main(void) if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } } + /* Big:2100 张图(超过默认 MR_LIB_SCAN_MAX=2000,复现评审 P1-1 截断错位场景)。 + * 1x1 BMP(stb 可解)以控制夹具体积/建盘时间。 */ + char big[MR_PATH_MAX]; + snprintf(big, sizeof big, "%s/Big", root); + if (mkdir_p(big) != 0) { printf("SKIP: mkdir Big failed\n"); return 0; } + for (int i = 1; i <= 2100; i++) { + snprintf(p, sizeof p, "%s/p%04d.bmp", big, i); + if (write_bmp24(p, 1, 1) != 0) { printf("SKIP: big image write failed\n"); return 0; } + } + meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } @@ -238,6 +267,90 @@ int main(void) mr_reader_ui_page(), want2); meow_view_page_pop(); + /* ---- P1-1 回归:单目录 2100 张图(超过默认 cap=2000)不得「点 A 开 B」 ---- */ + CHECK(library_test_load(root) == 1, "load root for cap regression"); + tap_row("Big"); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "tap Big dir stays in library"); + CHECK(strcmp(library_test_dir(), big) == 0, "entered Big (%s)", library_test_dir()); + CHECK(library_test_rows() == MR_LIB_SCAN_MAX + 1, + "Big rows=%d (cap + truncation row)", library_test_rows()); + + /* 截断提示行仍在,且文案不让人误以为「没有更多图」。 */ + meow_list_item trunc_it; + CHECK(library_test_src(library_test_rows() - 1, &trunc_it) == 1 && + trunc_it.enabled == 0 && trunc_it.title && strcmp(trunc_it.title, "已截断") == 0, + "truncation row present"); + CHECK(trunc_it.subtitle && strstr(trunc_it.subtitle, "2000") && + strstr(trunc_it.subtitle, "阅读器"), + "truncation text keeps images discoverable: %s", + trunc_it.subtitle ? trunc_it.subtitle : ""); + + /* 阅读器目录册是**全量** 2100 页(书库 cap 只是列表显示上限)。 */ + mr_dir_book db; + memset(&db, 0, sizeof db); + CHECK(mr_dir_book_init(&db, big, 720, cap_fake_load, cap_fake_unload, NULL) == 2100, + "reader sees all 2100 pages"); + + /* 首 3 + 尾 3 + 20 个确定性伪随机行,逐一点开断言「点哪张开哪张」。 */ + int picks[26], np = 0; + picks[np++] = 0; + picks[np++] = 1; + picks[np++] = 2; + picks[np++] = MR_LIB_SCAN_MAX - 3; + picks[np++] = MR_LIB_SCAN_MAX - 2; + picks[np++] = MR_LIB_SCAN_MAX - 1; + unsigned seed = 12345u; + for (int i = 0; i < 20; i++) { + seed = seed * 1103515245u + 12345u; + picks[np++] = (int)((seed >> 16) % MR_LIB_SCAN_MAX); + } + + int mismatched = 0; + for (int i = 0; i < np; i++) { + int row = picks[i]; + meow_list_item it; + memset(&it, 0, sizeof it); + CHECK(library_test_src(row, &it) == 1 && it.title, "pick %d: row source", row); + if (!it.title) continue; + int want_page = reader_page_of_name(&db, it.title); + CHECK(want_page >= 0, "pick %d: %s is a reader page", row, it.title); + CHECK(library_test_open_row(row) == 1, "open row %d", row); + CHECK(meow_ui_activity_active() == ACT_READER, "pick %d opens reader", row); + CHECK(strcmp(mr_reader_dir(), big) == 0, "pick %d reader dir", row); + CHECK(mr_reader_ui_count() == 2100, "pick %d reader pages=%d", row, + mr_reader_ui_count()); + if (mr_reader_ui_page() != want_page) mismatched++; + CHECK(mr_reader_ui_page() == want_page, + "pick %d: clicked %s -> page %d (want %d)", row, it.title, + mr_reader_ui_page(), want_page); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "pick %d back to library", row); + } + CHECK(mismatched == 0, "no click/page mismatch across %d picks", np); + + /* 被书库截掉的图(第 2001 张起)在阅读器里仍可打开/翻到。 */ + CHECK(reader_page_of_name(&db, "p2099.bmp") == 2098, "p2099 lives at page 2098"); + mr_reader_open_dir_at_name(big, "p2099.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "truncated-out image opens reader"); + CHECK(mr_reader_ui_count() == 2100, "truncated-out image: all pages present"); + CHECK(mr_reader_ui_page() == 2098, "truncated-out image opens at its page (%d)", + mr_reader_ui_page()); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library after truncated image"); + CHECK(library_test_rows() == MR_LIB_SCAN_MAX + 1, "library list unchanged after reads"); + mr_dir_book_free(&db); + + /* 名字找不到(文件已改名/删除):退回续读/首页,不崩、不串册。 */ + mr_reader_open_dir_at_name(pics, "does-not-exist.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "unknown name still opens reader"); + CHECK(strcmp(mr_reader_dir(), pics) == 0, "unknown name keeps target dir"); + CHECK(mr_reader_ui_count() == 6 && mr_reader_ui_open_err() == 0, + "unknown name falls back safely (pages=%d err=%d)", + mr_reader_ui_count(), mr_reader_ui_open_err()); + CHECK(mr_reader_ui_page() >= 0 && mr_reader_ui_page() < 6, + "unknown name fallback page in range (%d)", mr_reader_ui_page()); + meow_view_page_pop(); + /* 档案册路径(无 zip 夹具时只验证入口不被当成目录):点 txt 不存在行;跳过。 */ meow_view_free(reader); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 866481a..5a84afc 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -146,6 +146,14 @@ static void double_tap(int x, int y) pump_ms(120); } +/* 直接切模式(绕过顶栏 tap 的命中时序):本文件只关心绘制/解码,不验证 chrome 命中; + * 顶栏动作本身的回归在 test_mode_switch_host 覆盖。M18 评审:负载下该用例曾偶发。 */ +static void mode_toggle(void) +{ + mr_reader_test_toggle_mode(); + pump_ms(100); +} + static void draw_once(void) { meow_ui_paint_full(); } int main(void) @@ -239,7 +247,7 @@ int main(void) "paged drag: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); /* ---- P4 条漫模式:切换后滚动,长宽比 1.6 页也必须零重采样 ---- */ - tap(mode_x, bar_y); + mode_toggle(); CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon"); pump_ms(800); draw_once(); @@ -260,14 +268,14 @@ int main(void) mr_reader_ui_scaled_draws()); /* ---- P5 「先适高、再切条漫」:strip 恒按屏宽排版,解码必须跟随 ---- */ - tap(mode_x, bar_y); /* 回翻页 */ + mode_toggle(); /* 回翻页 */ CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "back to paged"); pump_ms(500); tap(fit_x, bar_y); /* 适宽 → 适高 */ CHECK(mr_reader_ui_fit() == MR_FIT_HEIGHT, "fit-height selected (got %d)", mr_reader_ui_fit()); pump_ms(700); - tap(mode_x, bar_y); /* 适高档 → 条漫 */ + mode_toggle(); /* 适高档 → 条漫 */ CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon after fit-height"); pump_ms(900); draw_once(); @@ -290,7 +298,7 @@ int main(void) mr_reader_ui_scaled_draws()); /* ---- P6 双击放大再缩回(评审 P2-1:缩回后必须按新几何重解)---- */ - tap(mode_x, bar_y); /* 回翻页 */ + mode_toggle(); /* 回翻页 */ CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "paged for zoom test"); pump_ms(600); draw_once(); @@ -338,7 +346,7 @@ int main(void) CHECK(mr_reader_ui_blit_draws() > bt && mr_reader_ui_scaled_draws() == st, "800x1280 paged drag: zero resample (blit +%d, scaled +%d)", mr_reader_ui_blit_draws() - bt, mr_reader_ui_scaled_draws() - st); - tap(mode_x, bar_y); /* 条漫 */ + mode_toggle(); /* 条漫 */ CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "800x1280 webtoon"); pump_ms(800); draw_once(); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index f7f7322..41bac1e 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -4,7 +4,8 @@ * * 浏览语义(M18,用户要求「完全像文件管理器」): * - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序; - * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at(目录, 图片下标)), + * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at_name(目录, 文件名), + * 由阅读器在全量目录册里定位起始页;列表被截断也不会点错图), * 点档案册=打开档案册(第 1 页/续读); * - 首帧只做**单层**枚举(一次 opendir/readdir+lstat):没有「目录含图=一本书」的探测、 * 页数、封面、角标,也没有懒算线程/缩略图解码——行副标题在扫描期生成一次; @@ -268,8 +269,7 @@ int library_go_up(void) char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; /* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */ - size_t rl = strlen(g_root_dir); - if (strlen(parent) < rl || strncmp(parent, g_root_dir, rl) != 0) + if (!mr_path_within_root(parent, g_root_dir)) snprintf(parent, sizeof parent, "%s", g_root_dir); if (!load_dir(parent)) { /* 父目录不可读 → 提示并停留(不越根、不丢当前列表)。 */ @@ -289,9 +289,9 @@ static void library_open_entry(int idx) return; } if (e->kind == MR_LIB_KIND_IMAGE) { - /* 下标由 logic 用与阅读器目录册相同的过滤器/比较器数出,保证「点 A 开 A」。 */ - int start = mr_library_image_index(&g_lib, idx); - mr_reader_open_dir_at(g_cur_dir, start < 0 ? 0 : start); + /* 按文件名定位起始页:由阅读器用自己的全量目录册算下标。 + * 书库列表可能被 MR_LIB_SCAN_MAX 截断,绝不能从列表推导下标(会点 A 开 B)。 */ + mr_reader_open_dir_at_name(g_cur_dir, e->name); return; } if (e->kind == MR_LIB_KIND_ARCHIVE) { @@ -401,8 +401,8 @@ void library_on_tick(void) /* ============================================================ */ static void library_page_create(meow_view *v, void *ctx) { - mr_page_ctrl *c = (mr_page_ctrl *)ctx; - c->view = v; + (void)v; + (void)ctx; int w = meow_ui_width(); int sb = meow_ui_statusbar_height(); @@ -525,6 +525,14 @@ int library_test_src(int idx, meow_list_item *item) return lib_src(idx, item, NULL); } +/* 直接打开第 idx 行(等价点击行体;测试里避免滚动/坐标干扰)。 */ +int library_test_open_row(int idx) +{ + if (idx < 0 || idx >= g_lib.count) return 0; + library_open_entry(idx); + return 1; +} + int library_test_hint_active(void) { return g_hint[0] != '\0'; diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 1fba5ef..96bb0d8 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -21,7 +21,7 @@ * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page * 经 meow_config 持久化(键 reader.mode/page.<册标识>,见 logic/reader.c),下次打开同一册 - * 自动恢复。书库行角标由 logic/library.c 扫描时读取同一份状态(副标题后缀「续读 Pn」)。 + * 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。 * * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; * 2.5s 超时则降级落位并提示)。 @@ -86,6 +86,11 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; static int g_start_at = MR_READER_START_RESUME; /* 下次 on_enter 的显式起始页(-1=续读) */ +/* 下次 on_enter 的「按文件名定位起始页」(书库点图;只对目录册生效,open_book 消费后清空)。 + * 用文件名而不是下标:书库列表可能被 MR_LIB_SCAN_MAX 截断,下标不可信。 */ +static char g_start_name[MR_NAME_MAX]; +/* 浏览根(生产=MR_ROOT_DIR,宿主测试可改):册间衔接不得越过它。 */ +static char g_root[MR_PATH_MAX] = MR_ROOT_DIR; static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; @@ -1433,10 +1438,28 @@ static void open_book_reset_state(void) g_meta = NULL; } -/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ +/* 在目录册里按文件名定位页下标:先精确,再大小写不敏感(书库展示名与磁盘名一致, + * 兜底只为容错)。-1=没找到。 */ +static int dir_book_find_index(const mr_dir_book *db, const char *name) +{ + if (!db || !name || !name[0] || db->count <= 0) return -1; + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcmp(db->names[i], name) == 0) return i; + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcasecmp(db->names[i], name) == 0) return i; + return -1; +} + +/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 + * 若 g_start_name 非空(书库点图),优先用**刚枚举出来的目录册**按 basename 定位起始页: + * 枚举源与翻页完全一致,且不受书库 MR_LIB_SCAN_MAX 截断影响。 */ static int open_book(const char *dir, int start_page, int mode) { if (!dir || !dir[0]) return 0; + char want_name[MR_NAME_MAX]; + snprintf(want_name, sizeof want_name, "%s", g_start_name); + g_start_name[0] = '\0'; /* 只在本次打开生效 */ + open_book_reset_state(); /* reader_page_enter 传入的 dir 就是 g_dir:自拷贝是 snprintf 的未定义行为, * 其它调用(册间衔接)才需要复制。 */ @@ -1472,6 +1495,12 @@ static int open_book(const char *dir, int start_page, int mode) meow_log(MEOW_LOG_WARN, "reader meta cache unavailable (%d pages)", g_count); int page = start_page < 0 ? g_count - 1 : start_page; + /* 书库点图:按文件名在刚打开的目录册里定位(与翻页同一份枚举/比较器)。 + * 找不到(文件已改名/删除)才退回 start_page/续读。 */ + if (want_name[0] && g_handle.kind == MR_BOOK_KIND_DIR) { + int idx = dir_book_find_index(&g_handle.u.dir, want_name); + if (idx >= 0) page = idx; + } page = mr_nav_clamp_page(page, g_count); if (page < 0) page = 0; @@ -1525,8 +1554,9 @@ static int book_switch(int delta) if (delta == 0) return 0; if (g_count <= 0) return 0; char next[MR_PATH_MAX]; - int r = mr_book_adjacent_ex(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next, - cbz_pages_probe, NULL); + /* 根锁定:相邻册必须仍在浏览根内,绝不跳到 SD 卡挂载点以上。 */ + int r = mr_book_adjacent_rooted(g_dir, delta > 0 ? 1 : -1, g_root, + cbz_pages_probe, NULL, next, (int)sizeof next); if (r != 1) { toast(delta > 0 ? "已是最后一册" : "已是第一册"); return 0; @@ -2040,7 +2070,6 @@ static void reader_state_reset(void) static void reader_page_enter(meow_view *v, void *ctx) { mr_page_ctrl *c = (mr_page_ctrl *)ctx; - c->view = v; c->active = 1; g_quitting = 0; @@ -2120,6 +2149,7 @@ static void reader_page_leave(meow_view *v, void *ctx) free(g_meta); g_meta = NULL; g_start_at = MR_READER_START_RESUME; + g_start_name[0] = '\0'; /* 未消费的文件名定位不跨页残留 */ mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } @@ -2160,7 +2190,9 @@ const char *mr_reader_dir(void) return g_dir; } -void mr_reader_open_dir_at(const char *path, int start_index) +/* 打开入口共用部分:已在本页时先 pop(完整 leave/enter 生命周期),再设目标并 push。 + * start_name 非空 = 按文件名定位(只对目录册),start_index 忽略。 */ +static void reader_open_common(const char *path, int start_index, const char *start_name) { if (!path) return; /* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER): @@ -2172,13 +2204,31 @@ void mr_reader_open_dir_at(const char *path, int start_index) if (reopen) meow_view_page_pop(); snprintf(g_dir, sizeof g_dir, "%s", path); - /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ - g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME - : (start_index < 0 ? 0 : start_index); - meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); + g_start_name[0] = '\0'; + if (start_name && start_name[0]) { + /* 按文件名定位:由 open_book 在刚枚举出的目录册里找下标(不受列表截断影响)。 */ + snprintf(g_start_name, sizeof g_start_name, "%s", start_name); + g_start_at = MR_READER_START_RESUME; + meow_log(MEOW_LOG_INFO, "reader open: %s (name=%s)", g_dir, g_start_name); + } else { + /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ + g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME + : (start_index < 0 ? 0 : start_index); + meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); + } meow_view_page_push(ACT_READER); } +void mr_reader_open_dir_at(const char *path, int start_index) +{ + reader_open_common(path, start_index, NULL); +} + +void mr_reader_open_dir_at_name(const char *dir, const char *name) +{ + reader_open_common(dir, MR_READER_START_RESUME, name); +} + void mr_reader_open_dir(const char *path) { mr_reader_open_dir_at(path, MR_READER_START_RESUME); @@ -2205,4 +2255,14 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } +/* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ +void mr_reader_test_toggle_mode(void) +{ + mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON : MR_MODE_PAGED, -1); +} +void mr_reader_test_set_root(const char *root) +{ + if (root && root[0]) snprintf(g_root, sizeof g_root, "%s", root); +} +const char *mr_reader_test_root(void) { return g_root; } #endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index f841316..e7562a0 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -19,6 +19,14 @@ void reader_on_touch_move(int x, int y); * 显式下标优先于续读记录:用户在列表里点了某张图,就不该被上次的页码带走。 */ #define MR_READER_START_RESUME (-1) void mr_reader_open_dir_at(const char *dir, int start_index); + +/* 书库 → 阅读器(按文件名定位起始页;M18 评审 P1-1 的首选入口): + * 打开 dir 后,由阅读器自己的目录册枚举(logic/reader/page_source.c: mr_dir_book_init, + * 与翻页用的是同一份全量枚举)按 basename 找起始页(先大小写敏感,再大小写不敏感兜底)。 + * 找不到则退回续读记录/首页。这样书库不需要(也不能)从可能被 MR_LIB_SCAN_MAX 截断的 + * 列表里推导下标——目录条目超过 2000 时也不会「点 A 开 B」。name 为 NULL/空则等价续读。 */ +void mr_reader_open_dir_at_name(const char *dir, const char *name); + /* 保留旧入口:等价于 mr_reader_open_dir_at(dir, MR_READER_START_RESUME)。 */ void mr_reader_open_dir(const char *dir); @@ -36,6 +44,11 @@ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重 int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ +/* 直接切模式(绕过顶栏 tap 的命中时序;测试用,产品路径仍走顶栏动作)。 */ +void mr_reader_test_toggle_mode(void); +/* 浏览根(生产=MR_ROOT_DIR):册间衔接不得越过它。测试可指向合成夹具根。 */ +void mr_reader_test_set_root(const char *root); +const char *mr_reader_test_root(void); #endif #ifdef __cplusplus From a07f9752d30e3c43fe643a9e63e584ad5a62c070 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 20:07:59 +0800 Subject: [PATCH 21/38] =?UTF-8?q?SDK:=20=E5=86=85=E7=BD=AE=20mipsel=20o32?= =?UTF-8?q?=20=E6=97=B6=E9=92=9F/=E7=9D=A1=E7=9C=A0=20ABI=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=88meow=5Fui=5Ftime.c=EF=BC=89+=20=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zig 0.17 内置 musl 在 mipsel 上头声明 64 位 time_t、实现走 time32 系统调用 (__ILP32__ 守卫),导致 meow_ui_time_ms() 毫秒位恒 0、高 32 位随机, usleep(16000) 立即返回(帧节拍/轮询变忙等空转)。主仓已验证的修复逐字节 搬入公开仓:取时按内核 o32 ABI(8 字节 {int32 sec; int32 nsec})再升 64 位, 同 TU 强定义 __clock_gettime/64 与睡眠入口,链接期接管 libc。 - meow_ui_time.c / CLOCK_ABI.md:cp 搬运,md5 与主仓一致(含原注释与依据链) - meow_ui_gfx.c:删掉 now_ms/meow_ui_time_ms(避免与 meow_ui_time.o 重复符号), 6 处内部调用改外部 meow_ui_time_ms; 不再使用 - meow_ui_internal.h:补 meow_time_ms_from_parts 声明 - build_sdk.sh:OBJS 首位加 meow_ui_time,头注释写明归档须排在 -lc 之前 - test_meow_time.c:适配公开仓构建方式,加负 sec 回绕两例,输出断言数(15) - sdk/README.md:公开说明本次修复、链接顺序要求与已知限制(2038 前有效) - tuku/test/run_tests.sh、run_webtoon_tests.sh:$SDK 源文件清单补 meow_ui_time.c (宿主集成测试直接编 SDK 源,否则链接期 undefined symbol) 验证:sdk/build_sdk.sh 0 warning;宿主 test_meow_time == PASS == checks=15; tuku 两套测试全 PASS(run_tests 462+106+325 断言、webtoon 240 断言,0 failed); 探针/ picbrowser 反汇编确认 __clock_gettime 取自本 TU(8 字节 time32 + 升位), 无 libc VDSO 前导;picbrowser 仍为 MIPS32r2 静态 ELF。 --- sdk/CLOCK_ABI.md | 187 +++++++++++++++++++++++++++++++++ sdk/README.md | 59 +++++++++++ sdk/build_sdk.sh | 9 +- sdk/meow_ui_gfx.c | 25 ++--- sdk/meow_ui_internal.h | 4 + sdk/meow_ui_time.c | 147 ++++++++++++++++++++++++++ sdk/test_meow_time.c | 111 +++++++++++++++++++ tuku/test/run_tests.sh | 10 +- tuku/test/run_webtoon_tests.sh | 2 +- 9 files changed, 531 insertions(+), 23 deletions(-) create mode 100644 sdk/CLOCK_ABI.md create mode 100644 sdk/README.md create mode 100644 sdk/meow_ui_time.c create mode 100644 sdk/test_meow_time.c diff --git a/sdk/CLOCK_ABI.md b/sdk/CLOCK_ABI.md new file mode 100644 index 0000000..f44cc64 --- /dev/null +++ b/sdk/CLOCK_ABI.md @@ -0,0 +1,187 @@ +# SDK 时钟 ABI 说明(meow_ui_time_ms 真机故障的根因与修复) + +面向对象:维护 SDK / 工具链 / 重链 App 的人。**结论先行**:这不是 SDK 代码写错, +是 zig 0.17 自带 musl 在 mipsel 上的 time64 ABI 自相矛盾;修复落在 SDK 链上 +(`patch_v2/sdk/meow_ui_time.c` + 链接期强定义),改后需重编 `meow_ui.a` 并重链四 App。 + +## 1. 现象(真机) + +M10 抓到的日志首列(`meow_ui_time_ms()`,64 位)可拆成: + +``` +6607363222717682072 → lo=1135000 hi=1538396632 +8754846870717682072 → lo=1135000 hi=2038396632 +18138485921460348192 → lo=1140000 hi=4223195352 +``` + +- 低 32 位 = 开机秒数 × 1000,**恒为 1000 的倍数 → 毫秒位丢失**; +- 高 32 位每次调用随机、无单调性。 + +同进程另一份取时(旧容器编的 core)正常 → 问题只在「zig 0.17 编的那部分」。 + +## 2. 根因链(三段证据) + +### 2.1 zig 头文件:`time_t` 是 64 位,`struct timespec` 16 字节 + +`/libc/include/mips-linux-musl/bits/alltypes.h`: + +```c +#define _REDIR_TIME64 1 +#define __USE_TIME_BITS64 1 +typedef _Int64 time_t; /* 无条件 64 位 */ +struct timespec { time_t tv_sec; ...; long tv_nsec; ... }; /* tv_sec@0(8B)、tv_nsec@8 */ +``` + +`include/generic-musl/time.h` 因此在 `_REDIR_TIME64` 下把 `clock_gettime` 重定向到 +`__clock_gettime64`。用该链编译的 C 代码一律按「16 字节、tv_sec 8 字节」取数 +(`patch_v2/sdk/meow_ui.a(meow_ui_gfx.o)` 的旧 `meow_ui_time_ms` 反汇编:`lw 0x10/0x14` +取 tv_sec、`lw 0x18` 取 tv_nsec,正是这个布局)。 + +### 2.2 zig 的 clang 给 mipsel 预定义了 `__ILP32__` + +``` +$ echo | zig cc --target=mipsel-linux-musleabi -dM -E - | grep __ILP32__ +#define __ILP32__ 1 +``` + +(arm-linux-musleabi 同样为 1;x86-linux-musl / aarch64 为 0。容器里的 gcc 对 mips o32 +**不定义** `__ILP32__`。) + +### 2.3 musl 的 time64 回退分支被这句 `!__ILP32__` 整体编译掉 + +`/libc/musl/src/time/clock_gettime.c`: + +```c +#if defined(SYS_clock_gettime64) && !defined(__ILP32__) + ... __syscall(SYS_clock_gettime64, clk, ts); /* 4403 */ + ... 失败(ENOSYS) 再 __syscall(SYS_clock_gettime, clk, ts32); /* 4263 + 宽度转换 */ +#else + r = __syscall(SYS_clock_gettime, clk, ts); /* 直接把 8 字节 time32 写进调用者缓冲 */ +#endif +``` + +这句 `!__ILP32__` 本意只排除 x32(`__ILP32__` 是 x32 的标记),但 clang 把它当成 +「32 位数据模型」的通名。于是 zig 的 mipsel libc 走进 `#else`: +`__clock_gettime64` 实际执行 **SYS_clock_gettime(4263)**,只写 8 字节 +`{ int32 tv_sec; int32 tv_nsec; }`。 + +**证据(反汇编,当前 zig 0.17 静态链接的探针)**: + +``` +0002576c <__clock_gettime64>: + 257c8: addiu $2,$zero,0x10a7 ; 4263 = SYS_clock_gettime(time32) + 257cc: syscall ; $5 直接是调用者的 16 字节 struct 指针,无 4403 尝试、无转换 +``` + +同一符号在**旧容器产物** `patch_v2/ncm_app/ncm`(已入库,gcc 构建)里是: + +``` +115748: addiu $2,$zero,0x1133 ; 4403 = SYS_clock_gettime64 +11574c: syscall +... +115774: addiu $2,$zero,0x10a7 ; ENOSYS 后回退 4263,写入本地 ts32 +... + ; 再把 ts32 升成 64 位写回调用者(tv_sec=tv_sec、tv_nsec=tv_nsec) +``` + +两者对比即根因:**头文件 ABI(64 位 time_t)与 libc 实现 ABI(32 位 time32)不一致**。 + +### 2.4 数值解释(为什么「低 32 = 秒×1000、高 32 随机」) + +libc 把 `{sec, nsec}` 当两个 int32 写进 16 字节缓冲的前 8 字节;SDK 按 64 位读: + +``` +tv_sec = sec | (nsec << 32) // 低字正确、高字=纳秒 +tv_nsec = 缓冲偏移 8 的未初始化值 // 毫秒项≈0 +result = tv_sec*1000 + tv_nsec/1e6 + = sec*1000 + (nsec*1000 mod 2^32)<<32 + 垃圾/1e6 +``` + +- 低 32 位只剩 `sec*1000`(nsec 项左移 32 位不落到低字)→ 恒为 1000 的倍数; +- 高 32 位 = `nsec*1000 mod 2^32` → 每次随机、不单调; +- `tv_nsec` 来自越界读 → 通常是小值/0(真机上恰好 0,所以没有额外尾数)。 + +## 3. 修复(`patch_v2/sdk/meow_ui_time.c`) + +1. **取数走内核 o32 ABI**:`meow_ui_time_ms()` 用 + `syscall(SYS_clock_gettime32, CLOCK_MONOTONIC, &{int32 sec; int32 nsec;})`, + 再 `meow_time_ms_from_parts(sec, nsec)` 升成 64 位毫秒。完全不依赖 libc 头声明的 + `struct timespec` 宽度 —— 即使将来 zig 修好(或换回容器链),内核 time32 入口始终存在。 +2. **链接期修 libc 入口**:同一 TU 强定义 `__clock_gettime` / `__clock_gettime64` + (musl 的 `time()`、`gettimeofday()`、`__timedwait_cp()`(pthread 定时等待)内部都调 + `__clock_gettime`)。应用链接顺序是 `... meow_ui.a ... -lc`,归档先解析符号,libc 里那份 + 坏实现(及其 VDSO 前导)不会被拉入 —— 用 `nm` 验证链接产物里没有 `cgt_init/__vdsosym`, + 且 `__time64`/`__timedwait_cp` 的 `jal` 指向本 TU 的实现。 +3. **睡眠入口一并修正(同源缺陷)**:`__nanosleep_time64` / `__clock_nanosleep(_time64)` + 先按 time32 转换再发 `SYS_nanosleep`/`SYS_clock_nanosleep`,`rem` 再扩回 64 位。 + 修复前 zig libc 把 16 字节 time64 结构直接交给 time32 系统调用,内核读到的 nsec 恒为 0 → + SDK 里 `usleep(16000)`(帧节拍、输入轮询、IME 冷却)全部变成忙等空转。 +4. **去掉四处重复的 `now_ms()`**:`meow_ui_gfx.c` / `meow_ui_sys.c` / `meow_ui_input.c` / + `meow_cachegov.c` 统一调 `meow_ui_time_ms()`(cachegov 保留宿主测试注入桩)。 +5. 构建脚本 `build_sdk.sh` 的 OBJS 增加 `meow_ui_time`;宿主单测 `test_meow_time.c` + 固定换算边界与「单调且前进」;探针 `tools/clock_abi_probe.c` 用于宿主/真机一眼验收。 + +非 mips 目标(宿主单测、aarch64 宿主)走普通 `clock_gettime`,行为不变。 + +## 4. 验证 + +宿主: + +``` +cd patch_v2/sdk +gcc -Os -Wall -Wextra -I. -Iinclude -c meow_ui_time.c -o /tmp/meow_ui_time.o +gcc -Os -Wall -Wextra -I. -Iinclude test_meow_time.c /tmp/meow_ui_time.o -o /tmp/test_meow_time +/tmp/test_meow_time # == PASS ==(换算边界 + 单调前进) +``` + +交叉: + +``` +bash patch_v2/sdk/build_sdk_host.sh --verify # 成员清单/架构自检 + 0 warning +llvm-objdump -d meow_ui.a(meow_ui_time.o) # meow_ui_time_ms: syscall 4263, lw 0x18 取 nsec、sra 扩展 tv_sec +``` + +链接期接管(用真实归档链一个探针,检查符号来源): + +``` +# 不应出现 libc 的 VDSO 前导(cgt_init/__vdsosym)→ libc 的取时实现未被拉入 +strings -a clock_abi_probe_sdk | grep -c __vdso # 期望 0 +llvm-nm clock_abi_probe_sdk | grep -E 'clock_gettime|nanosleep' +# __time64 / __timedwait_cp 的 jal 应指向本 TU 的 __clock_gettime;睡眠走 SYS_nanosleep(4166)/clock_nanosleep(4265) +``` + +探针 `tools/clock_abi_probe.c`(宿主 `gcc` 或 mipsel 交叉;加 `-DMEOW_PROBE_WITH_SDK` +并链 `meow_ui.a` 可同时验收 `meow_ui_time_ms`):把 `struct timespec` 预填 0xAA 后取时, +若偏移 8..11 仍是 0xAA 即说明 libc 只写了 8 字节(ABI 不一致),退出码 2。 + +真机(塔台批准后):日志首列应连续、出现非整千差;`meow_ui_time_ms()` 两次取样差 = 真实毫秒。 +本次设备已离线,真机确认**待办**。 + +## 5. 影响面 + +- **SDK 内**:`meow_ui_time_ms()` 及其上层 —— 动画 tween(`meow_ui_anim.c`)、滚动/fling + (`meow_ui_scroll.c`/`meow_select.c`)、IME 冷却(`meow_ui_ime.c`)、状态栏动画 + (`meow_ui_gfx.c`)、通知 TTL(`meow_ui_notify.c`)、输入层 tick/启动冷却 + (`meow_ui_input.c`)、cachegov 回退窗口(`meow_cachegov.c`)、日志首列(`meow_ui_log.c`)。 +- **App 侧**:`on_tick` 入参 `now`、`meow_anim_*`、以及任何自行调用 `meow_ui_time_ms()` 的 + App(hello/reader/files/ncm…)。 +- **产物**:`meow_ui.a` 变更 → 需**重编并重链四 App**(ncm/reader/files/hello); + `time()`/`gettimeofday()` 的修正也在同一份归档里生效(链接期覆盖)。 +- 真机写入红线未触碰;本单只改 SDK 源与构建脚本。 + +## 6. 同源残余(未在本单修复,需单独安排) + +同一句 `!__ILP32__` 还打穿了以下路径(睡眠族已在上文一并修掉,余下这些症状不同、 +修复面更大): + +| 路径 | 症状 | 说明 | +| --- | --- | --- | +| `futex` 相对超时 | 亚秒 timed wait 立即超时 | `__timedwait_cp` 的 `__futex4_cp` 同款 `!__ILP32__` 守卫;`pthread_cond_timedwait` 的「now」已由本单修正,但相对超时仍按 8 字节读(>1s 的整秒等待基本可用,亚秒等待失效) | +| `timerfd_settime` | 到期时间错乱 | `struct itimerspec`(32 字节)当 16 字节读;当前 SDK 未用 timerfd | +| `clock_settime`/`clock_adjtime`/`pselect`/`ppoll`/`sigtimedwait`/`semtimedop` 等 | 未验证 | 同族守卫;SDK 目前未直接使用 | + +建议:向 zig 上游报 musl `__ILP32__` 守卫问题(clang 对 32 位目标统一定义 `__ILP32__`, +而该守卫本意只排除 x32);根治应换用容器旧链(gcc,不定义 `__ILP32__`)编 SDK,或 +downgrade/修补 zig 内置 musl。**不要**指望 `-D_TIME_BITS=32`:zig 的 +`alltypes.h` 是无条件 `#define __USE_TIME_BITS64 1`,命令行改不动;而只改用户侧头文件会让 +用户结构(8 字节)与 libc 内部(16 字节,pthread 等)再次错配。 diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..a4d97f0 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,59 @@ +# sdk/ — meow_ui 应用框架(源码 + 构建) + +本目录是 `meow_ui.a` 的**自包含**源码树:公共头在 `include/`(伞头 `meow_ui.h`), +构建脚本 `build_sdk.sh`,宿主逻辑测试 `test_*.c`。上手指南见仓库根 +[README.md](../README.md) 与 [docs/APP_DEV_GUIDE.md](../docs/APP_DEV_GUIDE.md)。 + +## 构建 + +```sh +cd sdk +./build_sdk.sh # 产出 sdk/meow_ui.a(mipsel 静态库) +``` + +交叉编译器默认 `$HOME/Project/R3ProII/.tools/zig-mipsel-cc`,可用 `MEOW_CC` 覆盖。 + +## 时钟:mipsel o32 时钟/睡眠 ABI 修复(已内置) + +**公开 SDK 现已内置 mipsel o32 时钟/睡眠 ABI 修复(见 [CLOCK_ABI.md](CLOCK_ABI.md))**, +源码在 `meow_ui_time.c`。 + +- 背景:zig 内置 musl 在 mipsel 上「头文件声明 64 位 `time_t`、实现却走 time32 系统调用」, + 导致 `meow_ui_time_ms()` 毫秒位恒 0、高 32 位随机;`usleep()` 亚秒等待立即返回(忙等空转)。 +- 修复:`meow_ui_time_ms()` 直接按**内核 o32 ABI**(8 字节 `{int32 sec; int32 nsec;}`)取时, + 再自行升成 64 位毫秒;同一 TU 强定义 `__clock_gettime` / `__clock_gettime64` / + `__clock_nanosleep` / `__clock_nanosleep_time64` / `__nanosleep_time64`,在**链接期接管** + libc 里那份 ABI 不一致的实现。 +- **链接顺序要求**:应用必须把 `meow_ui.a` 放在 `-lc` **之前**(`cc ... meow_ui.a ... -lc`), + 强定义才能生效。仓库内 `examples/hello/build_hello.sh` 与 `tuku/build_tuku.sh` 均如此; + 自定义构建脚本请照抄这个顺序,否则取时/睡眠会静默回到坏实现。 +- 非 mipsel 目标(宿主单测、aarch64 等)走普通 `clock_gettime`,行为不变。 + +### 已知限制 + +- **2038 前有效**:内核侧仍是 time32(`tv_sec` 为 int32),秒数超出 `INT32_MAX` 即溢出; + 本修复只保证「头/实现的宽度一致」,不把内核升到 time64。 +- 同源缺陷**未修**:futex 相对超时(`pthread_cond_timedwait` 的亚秒等待)、`timerfd_settime`、 + `clock_settime`/`clock_adjtime`、`pselect`/`ppoll` 等仍受同一句 `!__ILP32__` 影响; + 清单与影响见 [CLOCK_ABI.md](CLOCK_ABI.md) §6。SDK 自身不直接使用这些路径。 +- 真机验收:本修复的证据是反汇编 + 宿主单测(本机无 qemu-mipsel),设备侧仍需实测确认。 + +## 宿主测试 + +无硬件即可跑(`gcc` 直接编译,不链 `meow_ui.a`): + +```sh +cd sdk +# 时钟换算与单调性 +gcc -Os -Wall -Wextra -I. -Iinclude -c meow_ui_time.c -o meow_ui_time_host.o +gcc -Os -Wall -Wextra -I. -Iinclude test_meow_time.c meow_ui_time_host.o -o test_meow_time +./test_meow_time # == PASS == / checks=N + +# 伞头与其余模块测试(示例) +gcc -I. -Iinclude test_umbrella.c <各 sdk 源 .c> test_host_stub.c -lpthread -o test_umbrella +./test_umbrella +``` + +注意:宿主编译时 `meow_ui_time.c` 走 `MEOW_TIME32_KERNEL_ABI 0` 分支(普通 `clock_gettime`), +因此**宿主测试不能证明 mipsel o32 路径正确** —— 那条路径的正确性靠 `CLOCK_ABI.md §4` 的 +反汇编证据(`nm` 符号来源 + `syscall 4263/4265` 的 8 字节转换)。 diff --git a/sdk/build_sdk.sh b/sdk/build_sdk.sh index 2b0bf4c..d03749f 100755 --- a/sdk/build_sdk.sh +++ b/sdk/build_sdk.sh @@ -6,6 +6,11 @@ # # 交叉编译器:默认 MeowOS 开发机路径 $HOME/Project/R3ProII/.tools/zig-mipsel-cc; # 可用环境变量 MEOW_CC 覆盖(或用你自己装好的 zig 交叉 cc,--target=mipsel-linux-musleabi soft-float)。 +# +# 时钟(meow_ui_time):mipsel 上 zig 内置 musl 的 time64 ABI 自相矛盾(头声明 64 位 time_t、 +# 实现走 time32 取数),该 TU 按内核 o32 ABI 取时并**强定义** __clock_gettime*/睡眠入口,靠链接期 +# 接管 libc 那份坏实现。因此应用链接顺序必须是 `... meow_ui.a ... -lc`(归档在 libc 之前, +# 现有 build_tuku.sh 即如此);根因与证据链见 sdk/CLOCK_ABI.md。 set -e cd "$(dirname "$0")" CC="${MEOW_CC:-$HOME/Project/R3ProII/.tools/zig-mipsel-cc}" @@ -16,7 +21,9 @@ rm -f meow_ui.a *.o # meow_font.c = 预渲染位图字体运行时(自包含:只含 meow_font.h + 标准头;最小 zlib ABI 自声明, # 链接时用 sysroot 的 libz.a),随 SDK 一并 vendored。新模块(box/anim/scroll/str + cachegov/lazygrid/ # select/net_ipc)随主仓增量并入。 -OBJS="meow_ui_gfx meow_ui_log meow_ui_notify meow_notify_ipc meow_ui_img meow_ui_widget meow_ui_input meow_ui_theme meow_ui_ime meow_pinyin meow_ui_sys meow_ui_vol meow_ui_activity meow_ui_global meow_ui_dialog meow_ui_config meow_ui_draw meow_ui_view meow_ui_nav meow_ui_media meow_ui_audio stb_image meow_font meow_ui_box meow_ui_anim meow_ui_scroll meow_ui_str meow_cachegov meow_lazygrid meow_select meow_net_ipc" +# meow_ui_time.c 必须**编进归档**且排在前面:它提供 meow_ui_time_ms(gfx 已不再实现)与 +# __clock_gettime* 强定义,归档成员随 meow_ui_time_ms 的引用被拉入,从而在 -lc 之前解析。 +OBJS="meow_ui_time meow_ui_gfx meow_ui_log meow_ui_notify meow_notify_ipc meow_ui_img meow_ui_widget meow_ui_input meow_ui_theme meow_ui_ime meow_pinyin meow_ui_sys meow_ui_vol meow_ui_activity meow_ui_global meow_ui_dialog meow_ui_config meow_ui_draw meow_ui_view meow_ui_nav meow_ui_media meow_ui_audio stb_image meow_font meow_ui_box meow_ui_anim meow_ui_scroll meow_ui_str meow_cachegov meow_lazygrid meow_select meow_net_ipc" CFLAGS="-Os -static -Iinclude -I." for f in $OBJS; do echo "== cc $f ==" diff --git a/sdk/meow_ui_gfx.c b/sdk/meow_ui_gfx.c index a73bf87..8418b0d 100644 --- a/sdk/meow_ui_gfx.c +++ b/sdk/meow_ui_gfx.c @@ -20,7 +20,6 @@ #include #include #include -#include #include #include @@ -172,15 +171,9 @@ static void refresh_governor(unsigned long long cost_ms, int period) } /* ---- 工具 ---- */ -static uint64_t now_ms(void) -{ - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u; -} - -uint64_t meow_ui_time_ms(void) { return now_ms(); } - +/* 时钟统一由 meow_ui_time.c 提供(meow_ui_time_ms,声明见 include/meow_ui_draw.h): + * mipsel 上 zig musl 的 time64 ABI 自相矛盾,取时必须走内核 o32 ABI 再升位, + * 故本 TU 不再保留取时实现(详见 sdk/CLOCK_ABI.md)。 */ static uint16_t rgb565(uint32_t c) { return (uint16_t)(((c >> 8) & 0xF800) | ((c >> 5) & 0x07E0) | ((c >> 3) & 0x001F)); @@ -895,7 +888,7 @@ static void statusbar_collapse_step(void) int tgt = g_sbar_collapsed ? MUI_SBAR_H : 0; if (g_sbar_collapse_h == tgt) return; if (g_sbar_collapse_anim_ms <= 0) { g_sbar_collapse_h = tgt; return; } - unsigned long long now = now_ms(); + unsigned long long now = meow_ui_time_ms(); unsigned long long el = now - g_sbar_collapse_start_ms; int span = g_sbar_collapse_anim_ms; if ((int)el >= span) { g_sbar_collapse_h = tgt; return; } @@ -932,7 +925,7 @@ void meow_ui_statusbar_set_collapsed(int on) if (tgt == g_sbar_collapsed) return; g_sbar_collapsed = tgt; g_sbar_collapse_from_h = g_sbar_collapse_h; - g_sbar_collapse_start_ms = now_ms(); + g_sbar_collapse_start_ms = meow_ui_time_ms(); dirty_full(); /* 顶栏显隐/上收变化 → 全帧重画 */ meow_ui_sbar_write_mode(); /* App 进程上报顶条模式(launcher 感知全屏/收合,agent-146) */ } @@ -1012,7 +1005,7 @@ int meow_ui_overlay_suppressed_active(void) snprintf(s_path, sizeof s_path, "%s", (e && e[0]) ? e : MEOW_NOTIF_ACTIVE_DEFAULT); s_path_init = 1; } - unsigned long long now = now_ms(); + unsigned long long now = meow_ui_time_ms(); if (s_last_chk && now - s_last_chk < MEOW_OVERLAY_CHK_MS) return s_last_res; s_last_chk = now; { @@ -1066,7 +1059,7 @@ static void present(void) } /* 帧率门控(A-1/A-2,从 launcher 迁入):present 距上次 ≥ period(1000/eff_rate) 才呈现; * 未满周期则保留脏区(不 dirty_reset),下一轮再试。refresh_always_max=1 时跳过门控(恒满速)。 */ - unsigned long long now = now_ms(); + unsigned long long now = meow_ui_time_ms(); int period = (g_eff_rate > 0) ? (1000 / g_eff_rate) : 16; if (!g_refresh_always_max) { if (g_last_present_ms && now - g_last_present_ms < (unsigned long long)period) return; @@ -1096,7 +1089,7 @@ static void present(void) } if (g_fb_mem) { ioctl(g_fb_fd, FBIO_WAITFORVSYNC, 0); /* 等垂直同步 */ - unsigned long long tbg = now_ms(); + unsigned long long tbg = meow_ui_time_ms(); /* 「同一个状态栏」闭环:App 进程不渲染状态栏(meow_ui_sys_render_enabled()==0),也绝不向 * 真实 fb 写顶条 inset 区(顶条由 launcher 系统进程叠加绘制)。否则 App 首帧全量拷贝 * (meow_ui_init 置 dirty_full)会把 launcher 刚叠画的顶条冲成未初始化数据——真机 @@ -1133,7 +1126,7 @@ static void present(void) } } } - refresh_governor(now_ms() - tbg, period); + refresh_governor(meow_ui_time_ms() - tbg, period); } g_last_present_ms = now; g_frames_presented++; diff --git a/sdk/meow_ui_internal.h b/sdk/meow_ui_internal.h index 83813b9..df64e0d 100644 --- a/sdk/meow_ui_internal.h +++ b/sdk/meow_ui_internal.h @@ -284,4 +284,8 @@ int meow_ime_handle_event(const meow_input_ev *e); /* 当前 IME 是否拼音模式(供 meow_ui_input.c 决定是否喂长按轮询)。 */ int meow_ime_mode_pinyin(void); +/* ---- 时钟(meow_ui_time.c)---- */ +/* 秒/纳秒 → 单调毫秒的唯一换算点(meow_ui_time_ms 与宿主单测共用)。 */ +uint64_t meow_time_ms_from_parts(int64_t sec, int64_t nsec); + #endif /* MEOW_UI_INTERNAL_H */ diff --git a/sdk/meow_ui_time.c b/sdk/meow_ui_time.c new file mode 100644 index 0000000..dd72a1d --- /dev/null +++ b/sdk/meow_ui_time.c @@ -0,0 +1,147 @@ +/* meow_ui_time.c — SDK 单调时钟(meow_ui_time_ms)+ mipsel o32 时间 ABI 兜底。 + * + * 根因(M11;真机日志 + 反汇编证据链见 sdk/CLOCK_ABI.md): + * zig 0.17 的 clang 前端给 32 位 mips 目标预定义了 __ILP32__;zig 自带 musl 的 + * time64 回退分支统一写作 `#if defined(SYS_x_time64) && !defined(__ILP32__)` + * (该 `!__ILP32__` 本意只排除 x32),于是 mipsel 的 libc 把「time64 入口」编译成了 + * 老 32 位实现:__clock_gettime64 直接做 SYS_clock_gettime(4263),只写 8 字节 + * `{ int32 tv_sec; int32 tv_nsec; }`。而 zig 头文件又无条件把 time_t 声明成 64 位 + * (struct timespec 16 字节、tv_sec 8 字节),两边 ABI 不一致: + * tv_sec 低字 = 真 sec,高字 = 真 nsec → 64 位结果高 32 位随机、非单调; + * tv_nsec(结构偏移 8)根本没被写 → 毫秒位恒 0(真机实测全是 1000 的倍数)。 + * 容器旧链(gcc)不定义 __ILP32__,time64 回退正确——对照 patch_v2/ncm_app/ncm: + * 同一函数里先 SYS_clock_gettime64(4403)、ENOSYS 后走 4263 并做宽度转换。 + * + * 修复:不依赖 libc 头声明的 struct timespec 宽度,直接按**内核 o32 ABI** + * (8 字节 time32:int32 sec + int32 nsec)取时,再自行升成 64 位毫秒; + * 同时在本 TU 给出 __clock_gettime / __clock_gettime64 的强定义。应用链接顺序是 + * `... meow_ui.a ... -lc`,归档先参与解析,因此链接期整体替换掉 libc 里那份 + * ABI 不一致的实现:time()、gettimeofday()、pthread 定时等待的内部取时 + * (musl 的 time()/__timedwait_cp 都调 __clock_gettime)随之修正。 + * 上层时间位宽仍是 64 位,只有内核取数按 o32 time32;2038 前有效。 + * + * 同一句 `!__ILP32__` 还打穿了睡眠:__nanosleep_time64/__clock_nanosleep 走 `#else` + * 把 16 字节 time64 结构直接交给 time32 系统调用,内核读到的 nsec 恒为 0 → + * `usleep(16000)` 立即返回(SDK 里帧节拍/轮询会变忙等空转)。这里一并按 time32 + * 转换后重发,恢复亚秒睡眠。 + * + * 残余(未修):futex 相对超时(pthread 定时等待的剩余时间按 8 字节读)、timerfd_settime、 + * clock_settime/adjtime、pselect/ppoll 超时——同为该守卫的受害者,见 CLOCK_ABI.md §6。 + */ +#include +#include +#include + +#include "meow_ui.h" + +#if defined(__mips__) && !defined(__mips64) && (__SIZEOF_LONG__ == 4) +#define MEOW_TIME32_KERNEL_ABI 1 +#include +#include +#else +#define MEOW_TIME32_KERNEL_ABI 0 +#endif + +uint64_t meow_time_ms_from_parts(int64_t sec, int64_t nsec) +{ + return (uint64_t)sec * 1000u + (uint64_t)nsec / 1000000u; +} + +uint64_t meow_ui_time_ms(void) +{ +#if MEOW_TIME32_KERNEL_ABI + struct meow_time32 { int32_t tv_sec; int32_t tv_nsec; } t; + t.tv_sec = 0; + t.tv_nsec = 0; + if (syscall(SYS_clock_gettime32, CLOCK_MONOTONIC, &t) != 0) return 0; + return meow_time_ms_from_parts(t.tv_sec, t.tv_nsec); +#else + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0; + return meow_time_ms_from_parts((int64_t)ts.tv_sec, (int64_t)ts.tv_nsec); +#endif +} + +#if MEOW_TIME32_KERNEL_ABI +/* 内核 o32 time32 布局(与 libc 头的 time_t 宽度无关)。 */ +struct meow_time32 { + int32_t tv_sec; + int32_t tv_nsec; +}; + +static int32_t time32_clamp(int64_t v) +{ + if (v > INT32_MAX) return INT32_MAX; + if (v < 0) return 0; + return (int32_t)v; +} + +/* ---- libc 取时入口的强定义 ---- + * 返回约定与 musl 一致:0 成功 / -1 失败(errno)。 + * 说明: 在 _REDIR_TIME64 下把标识符 clock_gettime 的引用改名为 + * __clock_gettime64,故本 TU 不定义 clock_gettime 这个名字;musl 里它只是 + * __clock_gettime 的 weak alias,同一份语义已由下面的强定义接管。 */ +int __clock_gettime(clockid_t clk, struct timespec *ts); +int __clock_gettime64(clockid_t clk, struct timespec *ts); + +int __clock_gettime(clockid_t clk, struct timespec *ts) +{ + struct meow_time32 t; + if (!ts) { errno = EFAULT; return -1; } + t.tv_sec = 0; + t.tv_nsec = 0; + if (syscall(SYS_clock_gettime32, clk, &t) != 0) return -1; + ts->tv_sec = t.tv_sec; + ts->tv_nsec = t.tv_nsec; + return 0; +} + +int __clock_gettime64(clockid_t clk, struct timespec *ts) +{ + return __clock_gettime(clk, ts); +} + +/* ---- 睡眠入口(同源缺陷:亚秒 usleep 变空转)---- + * __clock_nanosleep 按 musl 内部约定返回 0 或正 errno;__nanosleep_time64 是 + * nanosleep/usleep 的重定向目标,按公共约定返回 0/-1(errno)。 */ +int __clock_nanosleep(clockid_t clk, int flags, const struct timespec *req, struct timespec *rem); +int __clock_nanosleep_time64(clockid_t clk, int flags, const struct timespec *req, struct timespec *rem); +int __nanosleep_time64(const struct timespec *req, struct timespec *rem); + +int __clock_nanosleep(clockid_t clk, int flags, const struct timespec *req, struct timespec *rem) +{ + struct meow_time32 rq, rm; + long r; + + if (clk == CLOCK_THREAD_CPUTIME_ID) return EINVAL; + if (!req) return EFAULT; + if (req->tv_nsec < 0 || req->tv_nsec >= 1000000000L) return EINVAL; + rq.tv_sec = time32_clamp(req->tv_sec); + rq.tv_nsec = (int32_t)req->tv_nsec; + rm.tv_sec = 0; + rm.tv_nsec = 0; + + if (clk == CLOCK_REALTIME && !flags) + r = syscall(SYS_nanosleep, &rq, rem ? &rm : 0); + else + r = syscall(SYS_clock_nanosleep_time32, clk, flags, &rq, rem ? &rm : 0); + if (r != 0) return errno; + if (rem) { + rem->tv_sec = rm.tv_sec; + rem->tv_nsec = rm.tv_nsec; + } + return 0; +} + +int __clock_nanosleep_time64(clockid_t clk, int flags, const struct timespec *req, struct timespec *rem) +{ + return __clock_nanosleep(clk, flags, req, rem); +} + +int __nanosleep_time64(const struct timespec *req, struct timespec *rem) +{ + int r = __clock_nanosleep(CLOCK_REALTIME, 0, req, rem); + if (r) { errno = r; return -1; } + return 0; +} +#endif diff --git a/sdk/test_meow_time.c b/sdk/test_meow_time.c new file mode 100644 index 0000000..a27967c --- /dev/null +++ b/sdk/test_meow_time.c @@ -0,0 +1,111 @@ +/* test_meow_time.c — 宿主单测:SDK 时钟换算(meow_ui_time.c / meow_time_ms_from_parts)。 + * + * 背景(M11/M21):mipsel 真机上 meow_ui_time_ms() 曾返回「低 32 位=秒×1000、高 32 位随机」, + * 根因是 zig musl 头(time_t 64 位)与 libc 实现(o32 time32 取数)ABI 不一致,见 + * sdk/CLOCK_ABI.md。修复后取数统一走 meow_time_ms_from_parts(sec, nsec),本测试把 + * 换算边界(毫秒进位/截断/大秒数/负值回绕)与「真时钟单调且前进」固定在宿主上。 + * + * 注意:宿主(非 mips)编译时 meow_ui_time.c 走 MEOW_TIME32_KERNEL_ABI=0 分支,即普通 + * clock_gettime —— 本测试**不能**证明 mipsel o32 路径正确(那条路径只靠反汇编证据)。 + * + * 编译(在 sdk/ 目录下): + * gcc -Os -Wall -Wextra -I. -Iinclude -c meow_ui_time.c -o meow_ui_time_host.o + * gcc -Os -Wall -Wextra -I. -Iinclude test_meow_time.c meow_ui_time_host.o -o test_meow_time + * 运行:./test_meow_time (exit 0=PASS / 1=FAIL) + */ +#include +#include +#include + +#include "meow_ui.h" +#include "meow_ui_internal.h" + +static int g_fail = 0; +static unsigned long g_checks = 0; + +static void eq_ms(const char *name, int64_t sec, int64_t nsec, uint64_t want) +{ + uint64_t got = meow_time_ms_from_parts(sec, nsec); + g_checks++; + if (got != want) { + printf("FAIL %s: sec=%lld nsec=%lld got=%llu want=%llu\n", + name, (long long)sec, (long long)nsec, + (unsigned long long)got, (unsigned long long)want); + g_fail = 1; + } else { + printf("ok %s = %llu\n", name, (unsigned long long)got); + } +} + +int main(void) +{ + /* ---- 1) 换算边界:毫秒 = sec*1000 + nsec/1e6(整除截断) ---- */ + eq_ms("零", 0, 0, 0); + eq_ms("(sec,0) 整秒", 1, 0, 1000); + eq_ms("999999ns 截断", 0, 999999, 0); + eq_ms("1ms 进位", 0, 1000000, 1); + eq_ms("1ns 截断", 0, 1, 0); + eq_ms("开机 1135.008s", 1135, 8000000, 1135008); + eq_ms("跨秒 1.999999999s", 1, 999999999, 1999); + eq_ms("开机 1204.999999999s", 1204, 999999999, 1204999); + eq_ms("接近 2038", 2147483647, 999999999, 2147483647999ULL); + + /* ---- 2) 负值:from_parts 只做无符号升位((uint64_t)sec*1000 按二补回绕),不钳制。 + * 真正把内核 time32 的负 sec 钳成 0 的是 meow_ui_time.c 的 time32_clamp(),它只在 + * mipsel 分支编译、宿主不可达;这里把 from_parts 的回绕语义钉住,防止有人悄悄把它 + * 理解成「负值 = 0」而改掉换算。 ---- */ + eq_ms("负 sec 回绕(-1s)", -1, 0, 0xFFFFFFFFFFFFFC18ULL); + eq_ms("负 sec+1ms 回绕", -1, 1000000, 0xFFFFFFFFFFFFFC19ULL); + + /* ---- 3) 真时钟:非零、单调不减、量级合理(不上溢成「高 32 随机」) ---- */ + uint64_t prev = meow_ui_time_ms(); + g_checks++; + if (prev == 0) { + printf("FAIL 时钟返回 0(clock_gettime 失败?)\n"); + g_fail = 1; + } + g_checks++; + if (prev >= (1ULL << 40)) { + printf("FAIL 时钟量级异常(>=2^40 ms):%llu\n", (unsigned long long)prev); + g_fail = 1; + } + /* 有界采样(避免时钟停走时测试挂死):要求单调不减且确实前进 */ + int advanced = 0; + for (int i = 0; i < 200000 && !advanced; i++) { + uint64_t now = meow_ui_time_ms(); + if (now < prev) { + printf("FAIL 单调倒退:%llu -> %llu\n", + (unsigned long long)prev, (unsigned long long)now); + g_fail = 1; + break; + } + advanced = now > prev; + prev = now; + } + g_checks++; + if (!advanced) { + printf("FAIL 时钟未前进(200000 次采样内无变化)\n"); + g_fail = 1; + } else { + printf("ok 时钟前进且单调\n"); + } + + /* ---- 4) 动画常用差值:两次取样的差 = 毫秒(不能是 0 恒等/整千) ---- */ + uint64_t t0 = meow_ui_time_ms(); + int waited = 0; + for (int i = 0; i < 200000 && !waited; i++) + waited = meow_ui_time_ms() != t0; + uint64_t t1 = meow_ui_time_ms(); + g_checks++; + if (!waited || t1 <= t0 || t1 - t0 > 5000) { + printf("FAIL 取样差值异常:%llu -> %llu\n", + (unsigned long long)t0, (unsigned long long)t1); + g_fail = 1; + } else { + printf("ok 取样差值 %llu ms\n", (unsigned long long)(t1 - t0)); + } + + printf(g_fail ? "== FAIL ==\n" : "== PASS ==\n"); + printf("checks=%lu\n", g_checks); + return g_fail; +} diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 2c35f8a..0f8417e 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -63,7 +63,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ test/test_settings_lock_host.c ui/settings/settings.c ui/lock/lock.c \ logic/pin.c logic/reader.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ @@ -81,7 +81,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then test/test_browse_ui_host.c ui/library/library.c logic/library.c \ logic/reader.c logic/reader/page_source.c logic/reader/archive_source.c \ logic/reader/page_cache.c logic/zip/zip.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ @@ -99,7 +99,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then logic/library.c logic/reader.c logic/reader/page_source.c \ logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ logic/reader/nav.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ @@ -116,7 +116,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then logic/library.c logic/reader.c logic/reader/page_source.c \ logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ logic/reader/nav.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ @@ -133,7 +133,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then logic/library.c logic/reader.c logic/reader/page_source.c \ logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ logic/reader/nav.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ diff --git a/tuku/test/run_webtoon_tests.sh b/tuku/test/run_webtoon_tests.sh index 81f5d9d..bc945bb 100755 --- a/tuku/test/run_webtoon_tests.sh +++ b/tuku/test/run_webtoon_tests.sh @@ -34,7 +34,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then logic/library.c logic/reader.c logic/reader/page_source.c \ logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ logic/reader/nav.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ From 40751c09bd648a26fa313df3cb3c46d07ff1e629 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 21:19:33 +0800 Subject: [PATCH 22/38] =?UTF-8?q?Tuku=20reader:=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E5=B3=B0=E5=80=BC=E6=A8=A1=E5=9E=8B=20+=203M?= =?UTF-8?q?B=20=E7=A1=AC=E9=98=88=E5=80=BC=20+=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E5=8F=96=E6=B6=88=20+=203=20=E6=A7=BD=EF=BC=88M22=20=E6=AD=A2?= =?UTF-8?q?=E6=8D=9F=E7=89=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机(R3 Pro II 480×720 / MemTotal 56872kB):空闲 RSS 6044kB → 翻页 11400kB → 峰值 15600kB(MemFree 仅 1404kB)→ SIGKILL。M20 审计坐实根因是「单页解码瞬时峰值」, 且旧模型把 stb 当缩解库,对 JPEG 低估 3.5–8 倍(实测 baseline 2000×3000 +10.1MB、 渐进 +23.5MB、1200×1600 PNG +12.9MB)。本次按实测重定预算: - 真实峰值模型 decode_peak_bytes():JPEG 分量面(+渐进 short 系数面)、PNG 源缓冲 + RGBA 转换副本、BMP 直写目标,再叠精修/落框两段一次性重采样;上限常态 3MB (MR_DECODE_PEAK_CAP),只有 MemFree 真富余时才放宽到 min(12MB, MemFree-4MB)。 - 超预算时的降级:JPEG 逐步加粗 stbi 缩解档位(meow_img_load_file_step), PNG/BMP 无解码期缩放 → 占位;结果精确重采样回显示框(meow_img_resample), 命中页仍是单次 blit(M17 热路径纪律不破)。 - 占位可重试(g_page_skip 与永久失败 g_page_fail 分离):焦点页冷却 1.5s 自动重试、 中间区点按立即重试、离开再回来即重试;坏图仍是永久占位。 - 解码前内存门控:MemAvailable < 6MB 不解码(读不到就放行,不猜)。 - 解码请求取消:worker 出队后、mr_book_open 前二次判 gen/窗口;拖动/fling 期间只解落点页, 预取邻居仅当 MemAvailable ≥ 12MB。 - 先释放再解码:queue_page 按落地字节 slots_reserve() 先淘汰不可见旧图;可见页不淘汰。 - 槽位 4→3、常驻上限 9MB→3MB、结果队列 4→2。 - 可观测性:`reader decode page=N src=WxH peak=XkB memfree=YkB memavail=ZkB ok|skip=… step=S ms=M` 与进出阅读器 rss/hwm/memfree/memavail。 page_source: 头部探测新增 codec/progressive/channels/planes(mr_probe_info), 只带最小 SOF 的合成头仍能取尺寸(旧契约不退化)。 宿主证据:新增 test_reader_membudget(--wrap malloc 计真实峰值): PNG 600×960 model 4001kB vs 实测 3943kB;JPEG 1000×1500 step2 model 3749/实测 3711; 连翻 100 页 peak_transient 0kB(先释放再解码让缓冲复用)、驻留 ≤3MB、解码 100 次(取消生效)、 3MB 阈值下 1.5MP JPEG 可解、4MP JPEG 需 MemFree 放行(否则占位)、渐进式模型更高于 baseline。 run_tests.sh 全绿(含 zip 106 / cbz 325)、run_webtoon_tests.sh 全绿、SDK 与 tuku 交叉构建零警告。 --- sdk/include/meow_ui_image.h | 16 + sdk/meow_ui_img.c | 36 +- tuku/logic/reader/page_source.c | 148 ++++++-- tuku/logic/reader/page_source.h | 24 ++ tuku/test/make_mem_samples.py | 40 ++ tuku/test/run_tests.sh | 22 ++ tuku/test/test_reader_many_pages.c | 8 +- tuku/test/test_reader_membudget.c | 541 +++++++++++++++++++++++++++ tuku/ui/reader/reader.c | 577 ++++++++++++++++++++++------- tuku/ui/reader/reader.h | 11 +- 10 files changed, 1257 insertions(+), 166 deletions(-) create mode 100644 tuku/test/make_mem_samples.py create mode 100644 tuku/test/test_reader_membudget.c diff --git a/sdk/include/meow_ui_image.h b/sdk/include/meow_ui_image.h index 4427376..7cde639 100644 --- a/sdk/include/meow_ui_image.h +++ b/sdk/include/meow_ui_image.h @@ -34,6 +34,22 @@ int meow_img_probe_file(const char *path, int *w, int *h, int *channels); meow_img *meow_img_load_file_dim(const char *path, int maxdim); meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim); +/* 追加(M22)缩解档位选择:取满足「长边/档位 ≥ maxdim」的最大整数档 s ∈ {1,2,4,8}, + * 保证缩解结果不欠采样。meow_img_load_file_dim 内部用的就是这个函数;调用方(阅读器) + * 用它枚举候选档位做内存预算,避免两处各写一份而漂移。 */ +int meow_img_pick_scale(int longside, int maxdim); + +/* 追加(M22)显式档位解码:语义与 meow_img_load_file_dim 相同(结果长边 ≤ maxdim, + * 超了就再精修一次),只是缩解档位由调用方决定。内存吃紧时可以选更粗的档位——输出更小、 + * 峰值更低,代价是细节变软。step <= 1 等价长边约束入口;step > 8 按 8 处理。 + * 非 JPEG 格式 stb 没有解码期缩放,step 被忽略(整幅解码)。 */ +meow_img *meow_img_load_file_step(const char *path, int maxdim, int step); + +/* 追加(M22)精确重采样到 w×h(最近邻,缩小/放大都支持)。阅读器用它把「缩解档位」的 + * 结果一次性落到显示框,使命中页仍是单次原生 blit(不做每帧重采样)。同尺寸返回 NULL + * (调用方保留原图);失败返回 NULL。 */ +meow_img *meow_img_resample(meow_img *im, int w, int h); + int meow_img_w(const meow_img *img); int meow_img_h(const meow_img *img); /* 访问器:取 RGBA 像素指针(4 通道 straight alpha;供取色/直读像素)。meow_img 对外 opaque, diff --git a/sdk/meow_ui_img.c b/sdk/meow_ui_img.c index 9571800..31c33c2 100644 --- a/sdk/meow_ui_img.c +++ b/sdk/meow_ui_img.c @@ -45,7 +45,7 @@ meow_img *meow_img_load_file(const char *path) /* 追加(M12):预算解码。选整数缩解档位 s ∈ {1,2,4,8}——取满足「长边/s ≥ maxdim」的最大档, * 保证缩解结果不欠采样(不丢细节),再由 meow_img_downscale 精修到 maxdim。长边 < 2*maxdim * 时无档可用(s=1),退回整幅解码后精修——此时本就接近目标尺寸,无需牺牲。 */ -static int img_pick_scale(int longside, int maxdim) +int meow_img_pick_scale(int longside, int maxdim) { int s = 1; while (s < 8 && (longside / (s * 2)) >= maxdim) s *= 2; @@ -83,10 +83,40 @@ meow_img *meow_img_load_file_dim(const char *path, int maxdim) if (!stbi_info(path, &w, &h, &c)) return NULL; int longside = w > h ? w : h; if (longside <= maxdim) return meow_img_load_file(path); /* 小图不动:旧路径 */ - unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, img_pick_scale(longside, maxdim)); + unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, meow_img_pick_scale(longside, maxdim)); return img_finish_dim(p, w, h, maxdim); } +/* 追加(M22):档位由调用方指定。与 meow_img_load_file_dim 同语义(结果长边 ≤ maxdim, + * 超了再精修一次),只是档位不再由 maxdim 反推 —— 内存预算紧时可选更粗的档位。 */ +meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) +{ + if (!path) return NULL; + if (step <= 1 && maxdim <= 0) return meow_img_load_file(path); + if (step <= 0) step = 1; + if (step > 8) step = 8; + int w = 0, h = 0, c = 0; + if (!stbi_info(path, &w, &h, &c)) return NULL; + int longside = w > h ? w : h; + if (step == 1 && maxdim <= 0) return meow_img_load_file(path); + unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, step); + /* maxdim<=0:不精修(保留档位输出的原始尺寸)。 */ + return img_finish_dim(p, w, h, maxdim > 0 ? maxdim : longside); +} + +/* 追加(M22):精确重采样到目标尺寸(最近邻,双向)。阅读器把缩解档位的结果一次性 + * 落到显示框,之后命中页就是单次原生 blit(M17 的热路径纪律不被粗档位破坏)。 */ +static unsigned char *scale_buf(const meow_img *im, int dw, int dh, int cover); + +meow_img *meow_img_resample(meow_img *im, int w, int h) +{ + if (!im || w <= 0 || h <= 0) return NULL; + if (im->w == w && im->h == h) return NULL; + unsigned char *tmp = scale_buf(im, w, h, 0); + if (!tmp) return NULL; + return img_alloc(w, h, tmp); +} + meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim) { if (!buf || len <= 0) return NULL; @@ -97,7 +127,7 @@ meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim) if (longside <= maxdim) return meow_img_load_mem(buf, len); unsigned char *p = stbi_load_scaled_from_memory((const unsigned char *)buf, len, &w, &h, &c, 4, - img_pick_scale(longside, maxdim)); + meow_img_pick_scale(longside, maxdim)); return img_finish_dim(p, w, h, maxdim); } diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c index abab520..08f257d 100644 --- a/tuku/logic/reader/page_source.c +++ b/tuku/logic/reader/page_source.c @@ -192,7 +192,7 @@ static unsigned probe_be32(const unsigned char *p) return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3]; } -static int probe_png(probe_src *s, int *w, int *h) +static int probe_png(probe_src *s, mr_img_info *in) { unsigned char hdr[24]; if (!ps_read(s, hdr, sizeof hdr)) return 0; @@ -200,12 +200,33 @@ static int probe_png(probe_src *s, int *w, int *h) if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0; unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20); if (ww == 0 || hh == 0) return 0; - *w = (int)ww; - *h = (int)hh; + /* IHDR 的位深/颜色类型(紧跟在宽高后)。截断头(stb 也读不下去)按 RGB 8bit 记。 */ + int depth = 8, ctype = 2; + unsigned char ex[2]; + if (ps_read(s, ex, sizeof ex)) { depth = ex[0]; ctype = ex[1]; } + int ch; + switch (ctype) { + case 0: ch = 1; break; /* 灰度 */ + case 2: ch = 3; break; /* RGB */ + case 3: ch = 3; break; /* 调色板:stb 展开成 RGB(A) */ + case 4: ch = 2; break; /* 灰度+alpha */ + case 6: ch = 4; break; /* RGBA */ + default: ch = 3; break; + } + if (ww > 0x7FFFFFu || hh > 0x7FFFFFu) return 0; /* 防 int 溢出 */ + int bpc = (depth == 16) ? 2 : 1; + in->w = (int)ww; + in->h = (int)hh; + in->codec = MR_IMG_CODEC_PNG; + in->channels = ch; + in->progressive = 0; + /* ch==4:stb 直接解到目标 RGBA 缓冲(就是最终输出),中间面记 0。 + * 其余:stb 先解出 ch 通道源缓冲,再 convert_format 复制一张 RGBA。 */ + in->planes = (ch == 4) ? 0 : (long long)ww * hh * ch * bpc; return 1; } -static int probe_jpeg(probe_src *s, int *w, int *h) +static int probe_jpeg(probe_src *s, mr_img_info *in) { unsigned char b[2]; if (!ps_read(s, b, 2) || b[0] != 0xFF || b[1] != 0xD8) return 0; @@ -224,20 +245,61 @@ static int probe_jpeg(probe_src *s, int *w, int *h) if (len < 2) return 0; int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; if (is_sof) { + /* 5 字节就够拿宽高(精度 + 高 + 宽);分量数与采样因子能读就读。 + * 只带最小 SOF 的合成头(宿主测试夹具)也必须能取到尺寸——尺寸探测是 + * M4 起的硬契约,不能因为预算模型要更多信息就退化。 */ unsigned char so[5]; - if (len < 7 || !ps_read(s, so, 5)) return 0; + if (len < 7 || !ps_read(s, so, sizeof so)) return 0; int hh = ((int)so[1] << 8) | so[2]; int ww = ((int)so[3] << 8) | so[4]; if (ww <= 0 || hh <= 0) return 0; - *w = ww; - *h = hh; + int prog = (m == 0xC2 || m == 0xC6 || m == 0xCA || m == 0xCE); + in->w = ww; + in->h = hh; + in->codec = MR_IMG_CODEC_JPEG; + in->progressive = prog; + in->channels = 3; + /* 未知采样因子时按 4:4:4 估(3·W·H,偏保守):宁可多算峰值,不要低估。 */ + in->planes = (long long)ww * hh * 3; + unsigned char nc = 0; + if (ps_read(s, &nc, 1) && nc >= 1 && nc <= 4 && len >= 8 + 3 * nc) { + int hv[4] = { 0, 0, 0, 0 }; + int hmax = 1, vmax = 1, ok = 1; + for (int i = 0; i < nc && ok; i++) { + unsigned char comp[3]; /* id / 采样因子 hv / 量化表 */ + if (!ps_read(s, comp, sizeof comp)) { ok = 0; break; } + hv[i] = comp[1]; + int ch = hv[i] >> 4, cv = hv[i] & 15; + if (ch < 1 || ch > 4 || cv < 1 || cv > 4) { ok = 0; break; } + if (ch > hmax) hmax = ch; + if (cv > vmax) vmax = cv; + } + for (int i = 0; ok && i < nc; i++) { + if (hmax % (hv[i] >> 4) != 0 || vmax % (hv[i] & 15) != 0) ok = 0; + } + if (ok) { + /* 分量面字节 = 与 stb 完全同式的 w2×h2(MCU 对齐 + 采样因子); + * 渐进式每个分量还多一张 w2×h2×2 的 short 系数面(M20 实测 +23.5MB 的来源)。 */ + long long mcu_x = (ww + 8 * hmax - 1) / (8 * hmax); + long long mcu_y = (hh + 8 * vmax - 1) / (8 * vmax); + long long planes = 0; + for (int i = 0; i < nc; i++) { + long long w2 = mcu_x * (hv[i] >> 4) * 8; + long long h2 = mcu_y * (hv[i] & 15) * 8; + planes += w2 * h2; + if (prog) planes += w2 * h2 * 2; + } + in->channels = nc; + in->planes = planes; + } + } return 1; } if (!ps_skip(s, len - 2)) return 0; } } -static int probe_bmp(probe_src *s, int *w, int *h) +static int probe_bmp(probe_src *s, mr_img_info *in) { unsigned char hdr[26]; if (!ps_read(s, hdr, sizeof hdr)) return 0; @@ -248,49 +310,81 @@ static int probe_bmp(probe_src *s, int *w, int *h) ((unsigned)hdr[24] << 16) | ((unsigned)hdr[25] << 24)); if (hh < 0) hh = -hh; /* 自底向上 BMP */ if (ww <= 0 || hh <= 0) return 0; - *w = ww; - *h = hh; + unsigned char ex[4]; /* biPlanes(2) / biBitCount(2) */ + int bpp = 24; + if (ps_read(s, ex, sizeof ex)) bpp = (int)((unsigned)ex[2] | ((unsigned)ex[3] << 8)); + in->w = ww; + in->h = hh; + in->codec = MR_IMG_CODEC_BMP; + in->channels = bpp >= 8 ? bpp / 8 : 4; /* stb 一律解到 RGBA 目标 */ + in->progressive = 0; + in->planes = 0; /* stb 直接写 RGBA 目标,不另开源缓冲 */ return 1; } -static int probe_any(probe_src *s, int *w, int *h) +static int probe_any(probe_src *s, mr_img_info *in) { + memset(in, 0, sizeof *in); for (int pass = 0; pass < 3; pass++) { s->pos = 0; if (s->f) rewind(s->f); - int ok = (pass == 0) ? probe_png(s, w, h) - : (pass == 1) ? probe_jpeg(s, w, h) - : probe_bmp(s, w, h); + int ok = (pass == 0) ? probe_png(s, in) + : (pass == 1) ? probe_jpeg(s, in) + : probe_bmp(s, in); if (ok) return 1; + memset(in, 0, sizeof *in); } return 0; } -int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h) +int mr_probe_info_mem(const void *buf, size_t len, mr_img_info *info) { - if (w) *w = 0; - if (h) *h = 0; - if (!buf || len < 8 || !w || !h) return 0; + if (info) memset(info, 0, sizeof *info); + if (!buf || len < 8 || !info) return 0; probe_src s = { (const unsigned char *)buf, len, 0, NULL }; - if (!probe_any(&s, w, h) || *w <= 0 || *h <= 0) { - *w = *h = 0; + if (!probe_any(&s, info) || info->w <= 0 || info->h <= 0) { + memset(info, 0, sizeof *info); return 0; } return 1; } -int mr_probe_size(const char *path, int *w, int *h) +int mr_probe_info(const char *path, mr_img_info *info) { - if (w) *w = 0; - if (h) *h = 0; - if (!path || !path[0] || !w || !h) return 0; + if (info) memset(info, 0, sizeof *info); + if (!path || !path[0] || !info) return 0; FILE *f = fopen(path, "rb"); if (!f) return 0; probe_src s = { NULL, 0, 0, f }; - int ok = probe_any(&s, w, h); + int ok = probe_any(&s, info); fclose(f); - if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; } - return ok; + if (!ok || info->w <= 0 || info->h <= 0) { + memset(info, 0, sizeof *info); + return 0; + } + return 1; +} + +int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h) +{ + mr_img_info in; + if (w) *w = 0; + if (h) *h = 0; + if (!mr_probe_info_mem(buf, len, &in)) return 0; + if (w) *w = in.w; + if (h) *h = in.h; + return 1; +} + +int mr_probe_size(const char *path, int *w, int *h) +{ + mr_img_info in; + if (w) *w = 0; + if (h) *h = 0; + if (!mr_probe_info(path, &in)) return 0; + if (w) *w = in.w; + if (h) *h = in.h; + return 1; } /* ============================================================ */ diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h index b582278..18e29ee 100644 --- a/tuku/logic/reader/page_source.h +++ b/tuku/logic/reader/page_source.h @@ -88,6 +88,30 @@ int mr_probe_size(const char *path, int *w, int *h); /* 同上,但从内存缓冲探测(档案页解压后不需要落盘再读一遍)。 */ int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h); +/* ---- 追加(M22):解码内存预算需要的头部信息(仍然零像素内存) ---- + * 解析器按**内容**嗅探(PNG → JPEG → BMP),不信任扩展名:一个 .jpg 后缀的 PNG 也能 + * 得到正确结论。planes 是 stb 解码时**除最终 RGBA 输出缓冲以外**的中间面字节数: + * - JPEG:各分量面 w2*h2(按 MCU 对齐 + 采样因子,与 stb 同式),渐进式再加 short 系数面; + * - PNG :源像素缓冲 ch*W*H(16bit 再 ×2,ch==4 时就是输出缓冲本身,故记 0); + * - BMP :0(stb 对 24/32bpp 直接写 RGBA 目标,不另开源缓冲)。 + * 调用方(阅读器)再叠加「抽取输出 + 精修/落框临时图」得到真实峰值。 + * 1=已知(info 已填满),0=未知/损坏(info 清零且 codec=MR_IMG_CODEC_UNKNOWN)。 */ +#define MR_IMG_CODEC_UNKNOWN 0 +#define MR_IMG_CODEC_JPEG 1 +#define MR_IMG_CODEC_PNG 2 +#define MR_IMG_CODEC_BMP 3 + +typedef struct mr_img_info { + int w, h; + int codec; /* MR_IMG_CODEC_* */ + int progressive; /* 1=渐进式 JPEG(SOF2/SOF6/SOF10/SOF14) */ + int channels; /* 源通道数(JPEG 分量数 / PNG 颜色类型 / BMP 位深/8) */ + long long planes; /* 见上:stb 中间面字节(不含最终 RGBA 输出) */ +} mr_img_info; + +int mr_probe_info(const char *path, mr_img_info *info); +int mr_probe_info_mem(const void *buf, size_t len, mr_img_info *info); + /* ============================================================ */ /* 档案册(M11):.cbz / .zip 当一本书 */ /* ------------------------------------------------------------ diff --git a/tuku/test/make_mem_samples.py b/tuku/test/make_mem_samples.py new file mode 100644 index 0000000..07f4944 --- /dev/null +++ b/tuku/test/make_mem_samples.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""make_mem_samples.py — 为 test_reader_membudget.c 生成 JPEG 夹具(M22)。 + +用法:python3 make_mem_samples.py <输出目录> +依赖:Pillow。没有 Pillow 时测试自动 SKIP JPEG 断言(BMP/PNG 夹具由 C 直接写)。 + +刻意用**结构化图案**而不是噪声:文件极小、解码路径与真实漫画页一致(白底 + 分镜框)。 + page.jpg 1000x1500 baseline(1.5MP,真机崩溃页所在档位) + big4.jpg 2000x2000 baseline(4MP,手机照片档) + prog.jpg 1000x1500 progressive(SOF2:验证渐进识别 + 峰值模型) +""" +import os +import sys + +from PIL import Image, ImageDraw + + +def pattern(w, h): + im = Image.new("RGB", (w, h), (248, 247, 244)) + d = ImageDraw.Draw(im) + for k in range(0, min(w, h) - 200, 70): + d.rectangle([20 + k, 20 + k, 160 + k, 120 + k], outline=(10, 10, 12), width=3) + return im + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + return 2 + out = sys.argv[1] + os.makedirs(out, exist_ok=True) + pattern(1000, 1500).save(os.path.join(out, "page.jpg"), "JPEG", quality=60, subsampling=2) + pattern(2000, 2000).save(os.path.join(out, "big4.jpg"), "JPEG", quality=60, subsampling=2) + pattern(1000, 1500).save(os.path.join(out, "prog.jpg"), "JPEG", quality=60, subsampling=2, + progressive=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 0f8417e..7ea49c6 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -13,6 +13,9 @@ # 交付环境只有库+头时自动 SKIP。 # - test_open_book_host(可选):书库点击「封面图 + 含图子目录」的父目录 → 阅读器打开的 # 完整 UI 链路回归(合成夹具,不读真实相册);同样需要 SDK 源码树。 +# - test_reader_membudget(可选,M22):--wrap malloc 计真实解码峰值 → 单页 ≤3MB 硬阈值、 +# 连翻 100 页峰值不增长、内存门控/预算跳过可重试、常驻 ≤3MB、取消机制有界。JPEG 夹具由 +# test/make_mem_samples.py 生成(无 Pillow 时该项 SKIP)。 # reader.c 的方向持久化只 include 配置/日志两个模块头;宿主测试用 test/settings_reader_stub.c # 顶替 meow_config_* 与 meow_log(所以下面各条都带 -I"$SDK" 与该存根)。 # logic/library.c 的探测缓存用 pthread 互斥锁 → 相关编译带 -pthread。 @@ -143,6 +146,25 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_reader_many_pages "$OUT"/test_reader_many_pages + + # 解码内存预算(M22):--wrap malloc 计真实峰值 → 单页 ≤3MB、连翻 100 页峰值不增长、 + # 内存门控/预算跳过可重试、常驻 ≤3MB。真机 SIGKILL 事故的回归防线。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_reader_membudget.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_reader_membudget \ + -Wl,--wrap=malloc -Wl,--wrap=calloc -Wl,--wrap=realloc -Wl,--wrap=free + "$OUT"/test_reader_membudget else echo "SKIP test_settings_lock_host/test_browse_ui_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi diff --git a/tuku/test/test_reader_many_pages.c b/tuku/test/test_reader_many_pages.c index e1fcf01..c08536d 100644 --- a/tuku/test/test_reader_many_pages.c +++ b/tuku/test/test_reader_many_pages.c @@ -273,9 +273,11 @@ int main(void) CHECK(bad_ok, "flip across corrupt/huge pages keeps working (page=%d)", mr_reader_ui_page()); CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "huge page skipped without allocating it (%ldKB)", mr_reader_ui_resident_kb()); - CHECK(mr_reader_ui_failed_pages() == 2, - "garbage + oversized page both degraded to placeholder (failed=%d)", - mr_reader_ui_failed_pages()); + /* M22:坏图 = 永久占位(failed);超预算大页 = 预算跳过(skipped,可重试,不是 failed)。 */ + CHECK(mr_reader_ui_failed_pages() == 1, + "garbage page is a permanent placeholder (failed=%d)", mr_reader_ui_failed_pages()); + CHECK(mr_reader_ui_skipped_pages() == 1, + "oversized page is a retryable budget skip (skipped=%d)", mr_reader_ui_skipped_pages()); CHECK(meow_ui_activity_active() == ACT_READER, "still in reader after corrupt page"); /* ---- M5 条漫:140 页排版 + 跳页落位 ---- */ diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c new file mode 100644 index 0000000..6a680a5 --- /dev/null +++ b/tuku/test/test_reader_membudget.c @@ -0,0 +1,541 @@ +/* test_reader_membudget.c — 阅读器解码内存预算回归(M22;需要 SDK 源码树)。 + * + * 真机事故(R3 Pro II 480×720 / MemTotal 56872kB):空闲 RSS 6044kB → 翻页 11400kB + * → 峰值 15600kB(当时 MemFree 仅 1404kB)→ SIGKILL。M20 审计把账算清:4 槽 RGBA 常驻 + * 5356kB、单页解码尖峰 3.4–35MB(旧模型低估 3.5–8 倍)。本测试用 `--wrap malloc` 量 + * **真实分配峰值**(不是 VmHWM 估计),把「模型 / 阈值 / 驻留」钉在实测上。 + * + * 断言: + * B1 峰值模型 ≤ 实测(模型必须是不低估的上界;同时 ≤ 实测×2,避免离谱保守) + * B2 单页解码峰值(预算内的页)≤ 3MB 硬阈值 + * B3 连翻 100 页:峰值不增长、无永久失败页、解码次数有界(取消生效) + * B4 常驻页图 ≤ 常驻上限(3MB) + * B5 内存门控:MemAvailable 不足 → 不解码直接占位(可重试,不是永久失败) + * B6 预算跳过可重试:MemFree 充足时同一页能解出来(占位不是永久 failed) + * B7 快翻取消:拖动期间只解落点页,不产生中间页解码 + * + * 夹具:BMP/PNG 由本文件用 C 写(不依赖 Pillow);JPEG 由 python3+Pillow 生成, + * 缺 PIL 时跳过 JPEG 相关断言(打印 SKIP)。全部夹具体积极小(结构化图案)。 + * + * 编译:test/run_tests.sh(与 test_reader_perf 同口径 + --wrap malloc 家族)。 + */ +#define _GNU_SOURCE +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "meow_ui_internal.h" +#include "logic/reader/nav.h" + +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 分配打点:--wrap malloc/free 家族(与 sdk/test_meow_img_budget.c 同法)*/ +/* ------------------------------------------------------------------ */ +#define AHDR 32 +#define AMAGIC 0x6D6B4D32324D454DU /* 只统计本包装器的分配;libc 内部(strdup 等)原样透传 */ + +static size_t g_live = 0; +static size_t g_peak = 0; + +extern void *__real_malloc(size_t); +extern void *__real_calloc(size_t, size_t); +extern void *__real_realloc(void *, size_t); +extern void __real_free(void *); + +static void acc_alloc(size_t n) +{ + g_live += n; + if (g_live > g_peak) g_peak = g_live; +} + +void *__wrap_malloc(size_t n) +{ + size_t *h = (size_t *)__real_malloc(n + AHDR); + if (!h) return NULL; + h[0] = n; + h[1] = (size_t)AMAGIC; + acc_alloc(n); + return (char *)h + AHDR; +} + +void *__wrap_calloc(size_t a, size_t b) +{ + size_t n = a * b; + void *p = __wrap_malloc(n); + if (p) memset(p, 0, n); + return p; +} + +void __wrap_free(void *p) +{ + size_t *h; + if (!p) return; + h = (size_t *)((char *)p - AHDR); + if (h[1] != (size_t)AMAGIC) { __real_free(p); return; } /* 非本包装器的分配 */ + h[1] = 0; + g_live -= h[0]; + __real_free(h); +} + +void *__wrap_realloc(void *p, size_t n) +{ + size_t *h = p ? (size_t *)((char *)p - AHDR) : NULL; + if (p && (!h || h[1] != (size_t)AMAGIC)) return __real_realloc(p, n); /* 外来指针 */ + size_t old = h ? h[0] : 0; + void *q = __wrap_malloc(n); + if (!q) return NULL; + if (p) { + memcpy(q, p, old < n ? old : n); + __wrap_free(p); + } + return q; +} + +static size_t peak_reset(void) { g_peak = g_live; return g_live; } +static size_t peak_since(size_t base) { return g_peak - base; } + +void mr_nav_report(void) {} + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) +#define INFO(...) do { printf(" info: "); printf(__VA_ARGS__); printf("\n"); } while (0) + +/* ------------------------------------------------------------------ */ +/* 夹具写入 */ +/* ------------------------------------------------------------------ */ +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32be(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)(v >> 24); p[1] = (unsigned char)(v >> 16); + p[2] = (unsigned char)(v >> 8); p[3] = (unsigned char)v; +} +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +static int write_bmp24(const char *path, int w, int h, unsigned seed) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < w; x++) { + row[x * 3 + 0] = (unsigned char)((x + seed) & 0xFF); + row[x * 3 + 1] = (unsigned char)((y * 3 + seed) & 0xFF); + row[x * 3 + 2] = (unsigned char)((x ^ y) & 0xFF); + } + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static unsigned crc32_buf(const unsigned char *p, size_t n) +{ + unsigned c = 0xFFFFFFFFu; + for (size_t i = 0; i < n; i++) { + c ^= p[i]; + for (int k = 0; k < 8; k++) c = (c >> 1) ^ (0xEDB88320u & (unsigned)(-(int)(c & 1))); + } + return c ^ 0xFFFFFFFFu; +} + +static int write_png_rgb(const char *path, int w, int h, unsigned seed) +{ + /* zlib 流用 stored 块(不依赖 zlib),只为造一个真 PNG 让 stb 走「源缓冲 + RGBA 转换」路径。 */ + size_t rowlen = (size_t)w * 3 + 1; + size_t rawlen = rowlen * (size_t)h; + unsigned char *raw = (unsigned char *)malloc(rawlen); + if (!raw) return -1; + for (int y = 0; y < h; y++) { + unsigned char *r = raw + rowlen * (size_t)y; + r[0] = 0; /* filter none */ + for (int x = 0; x < w; x++) { + r[1 + x * 3 + 0] = (unsigned char)((x + seed) & 0xFF); + r[1 + x * 3 + 1] = (unsigned char)(y & 0xFF); + r[1 + x * 3 + 2] = (unsigned char)((x ^ y) & 0xFF); + } + } + size_t nblk = (rawlen + 65534) / 65535; + size_t zlen = 2 + rawlen + nblk * 5 + 4; + unsigned char *z = (unsigned char *)malloc(zlen); + if (!z) { free(raw); return -1; } + size_t zi = 0; + z[zi++] = 0x78; z[zi++] = 0x01; + for (size_t off = 0; off < rawlen; off += 65535) { + size_t n = rawlen - off > 65535 ? 65535 : rawlen - off; + z[zi++] = (off + n == rawlen) ? 1 : 0; /* BFINAL */ + z[zi++] = (unsigned char)(n & 0xFF); + z[zi++] = (unsigned char)(n >> 8); + z[zi++] = (unsigned char)(~n & 0xFF); + z[zi++] = (unsigned char)((~n >> 8) & 0xFF); + memcpy(z + zi, raw + off, n); + zi += n; + } + /* adler32(大端,zlib 流尾) */ + { + unsigned a = 1, b = 0; + for (size_t i = 0; i < rawlen; i++) { a = (a + raw[i]) % 65521; b = (b + a) % 65521; } + put32be(z + zi, (b << 16) | a); + zi += 4; + } + free(raw); + + FILE *f = fopen(path, "wb"); + if (!f) { free(z); return -1; } + static const unsigned char sig[8] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }; + int ok = fwrite(sig, 1, 8, f) == 8; + unsigned char ihdr[13] = { 0 }; + put32be(ihdr + 0, (unsigned)w); + put32be(ihdr + 4, (unsigned)h); + ihdr[8] = 8; /* bit depth */ + ihdr[9] = 2; /* color type RGB */ + struct { const char *tag; const unsigned char *data; unsigned len; } chunks[2] = { + { "IHDR", ihdr, 13 }, { "IDAT", z, (unsigned)zi }, + }; + for (int k = 0; ok && k < 2; k++) { + unsigned char lenb[4], crcb[4]; + put32be(lenb, chunks[k].len); + if (fwrite(lenb, 1, 4, f) != 4) ok = 0; + if (fwrite(chunks[k].tag, 1, 4, f) != 4) ok = 0; + if (fwrite(chunks[k].data, 1, chunks[k].len, f) != chunks[k].len) ok = 0; + unsigned crc = crc32_buf((const unsigned char *)chunks[k].tag, 4); + /* 逐段续算:简化起见重算整块 */ + { + unsigned char *tmp = (unsigned char *)malloc(4 + chunks[k].len); + if (tmp) { + memcpy(tmp, chunks[k].tag, 4); + memcpy(tmp + 4, chunks[k].data, chunks[k].len); + crc = crc32_buf(tmp, 4 + chunks[k].len); + free(tmp); + } + } + put32be(crcb, crc); + if (fwrite(crcb, 1, 4, f) != 4) ok = 0; + } + if (ok) { + unsigned char iend[12] = { 0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82 }; + if (fwrite(iend, 1, 12, f) != 12) ok = 0; + } + free(z); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +static void tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(300); +} + +/* 生成 JPEG 夹具(Pillow 不可用则返回 0,相关断言 SKIP)。脚本本体在 + * test/make_mem_samples.py(与本文件同目录;run_tests.sh 已 cd 到 tuku/)。 */ +static int gen_jpeg_fixtures(const char *dir) +{ + char cmd[512]; + snprintf(cmd, sizeof cmd, "python3 test/make_mem_samples.py '%s' >/dev/null 2>&1", dir); + if (system(cmd) != 0) return 0; + struct stat st; + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/page.jpg", dir); + return stat(p, &st) == 0 && st.st_size > 0; +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_membudget_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX]; + snprintf(root, sizeof root, "./mr_membudget_%ld", (long)getpid()); + char book[MR_PATH_MAX], big[MR_PATH_MAX], pngbook[MR_PATH_MAX], jpgbook[MR_PATH_MAX]; + snprintf(book, sizeof book, "%s/small", root); + snprintf(big, sizeof big, "%s/big", root); + snprintf(pngbook, sizeof pngbook, "%s/png", root); + snprintf(jpgbook, sizeof jpgbook, "%s/jpg", root); + if (mkdir_p(book) != 0 || mkdir_p(big) != 0 || mkdir_p(pngbook) != 0 || + mkdir_p(jpgbook) != 0) { + printf("SKIP: mkdir failed\n"); + return 0; + } + /* 480x640 BMP ×8:单页解码峰值 ≈ 480*640*4*2 ≈ 2.5MB,能进 3MB 预算(可连翻)。 */ + for (int i = 0; i < 8; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%02d.bmp", book, i); + if (write_bmp24(p, 480, 640, (unsigned)i) != 0) { printf("SKIP: bmp write\n"); return 0; } + } + /* 1200x1600 BMP:模型峰值 ≈ 7.7MB + 目标 ≈ 9MB —— 3MB 常态预算下必须占位, + * MemFree 富余(放宽到 12MB)时必须能解出来。 */ + { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/huge.bmp", big); + if (write_bmp24(p, 1200, 1600, 3) != 0) { printf("SKIP: bmp write\n"); return 0; } + } + /* 600x960 PNG(RGB):走「源缓冲 + RGBA 转换」路径(stb 不能解码期缩解)。 */ + for (int i = 0; i < 3; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/q%d.png", pngbook, i); + if (write_png_rgb(p, 600, 960, (unsigned)i) != 0) { printf("SKIP: png write\n"); return 0; } + } + int have_jpeg = gen_jpeg_fixtures(jpgbook); + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { 0 }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + /* 宿主内存读数固定:3MB 常态预算生效(不被开发机的巨大 MemFree 放宽)。 */ + mr_reader_ui_test_set_mem(4096, 16000); + + /* ---- B1/B2 单页解码峰值 vs 模型(PNG:stb 不能解码期缩解 → 整幅落地)---- */ + { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/q0.png", pngbook); + long long model = mr_reader_ui_decode_peak_kb(p, 1, 480, 768); + size_t b0 = g_live; + peak_reset(); + /* 走产品解码入口(含落框重采样),与阅读器 worker 完全同路径 */ + meow_img *im = meow_img_load_file_step(p, 768, 1); + if (im && (meow_img_w(im) != 480 || meow_img_h(im) != 768)) { + meow_img *fit = meow_img_resample(im, 480, 768); + if (fit) { meow_img_free(im); im = fit; } + } + size_t measured_kb = peak_since(b0) / 1024 + 1; + int got = im ? 1 : 0; + if (im) meow_img_free(im); + INFO("png 600x960: model=%lldkB measured=%zukB decoded=%d", model, measured_kb, got); + CHECK(got, "png fixture decodes (writer valid)"); + CHECK(model >= (long long)measured_kb, "model is an upper bound (%lldkB < %zukB)", + model, measured_kb); + CHECK(model <= (long long)measured_kb * 2, "model not wildly pessimistic (%lldkB vs %zukB)", + model, measured_kb); + } + + /* ---- B5 内存门控:MemAvailable 不足 → 不解码、占位(可重试)---- */ + { + mr_reader_ui_test_set_mem(20000, 1000); /* MemAvailable 1MB < 6MB 下限 */ + mr_reader_open_dir(book); + CHECK(mr_reader_ui_count() == 8, "book pages=%d", mr_reader_ui_count()); + int d0 = mr_reader_ui_decode_count(); + pump_ms(600); + CHECK(mr_reader_ui_decode_count() - d0 <= 1, + "low mem: no decode attempt (%d new decodes)", mr_reader_ui_decode_count() - d0); + CHECK(mr_reader_ui_skipped_pages() > 0, "low mem: page skipped (skipped=%d)", + mr_reader_ui_skipped_pages()); + CHECK(mr_reader_ui_failed_pages() == 0, "low mem: not a permanent failure (failed=%d)", + mr_reader_ui_failed_pages()); + + /* 内存恢复 + 点按显式重试 → 同一页能解出来(可重试语义) */ + mr_reader_ui_test_set_mem(20000, 20000); + int y = meow_ui_height() / 2; + tap(meow_ui_width() / 2, y); + pump_ms(500); + CHECK(mr_reader_ui_skipped_pages() == 0, "retry after mem recovers (skipped=%d)", + mr_reader_ui_skipped_pages()); + CHECK(mr_reader_ui_resident_kb() > 0, "retry landed a page image (resident=%ldkB)", + mr_reader_ui_resident_kb()); + meow_view_page_pop(); + pump_ms(200); + } + + /* ---- B3/B4 连翻 100 页:真实分配峰值不增长、常驻有界、取消有界 ---- */ + { + mr_reader_ui_test_set_mem(4096, 16000); + mr_reader_open_dir(book); + pump_ms(800); + int w = meow_ui_width(); + int d0 = mr_reader_ui_decode_count(); + long long resident_cap = mr_reader_ui_resident_cap_kb(); + long long cap = mr_reader_ui_peak_cap_kb(); + /* 基线 = 首屏稳定后的存活字节;之后任何分配都不该再超过 基线+3MB(= 单页解码峰值)。 */ + size_t live_steady = g_live; + size_t peak_steady = g_peak; + int skipped_max = 0, advanced = 0; + for (int i = 0; i < 100; i++) { + int before = mr_reader_ui_page(); + tap(i % 2 == 0 ? 40 : w - 40, 300); /* RTL:左 1/3=下一页 */ + pump_ms(120); /* 覆盖滑页 + 紧随其后的预取解码 */ + if (mr_reader_ui_page() != before) advanced++; + if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); + meow_ui_paint_full(); + long kb = mr_reader_ui_resident_kb(); + if (kb > resident_cap) { + g_fail++; + printf(" FAIL flip %d: resident %ldkB > cap %lldkB\n", i, kb, resident_cap); + break; + } + } + size_t transient = g_peak > peak_steady ? g_peak - peak_steady : 0; + size_t live_end = g_live; + int decodes = mr_reader_ui_decode_count() - d0; + INFO("100 flips: steady_live=%zuKB peak_transient=%zuKB end_live=%zuKB cap=%lldKB decodes=%d advanced=%d", + live_steady / 1024, transient / 1024, live_end / 1024, cap, decodes, advanced); + CHECK(advanced == 100, "every flip advanced the page (%d/100)", advanced); + CHECK(transient <= (size_t)cap * 1024 + 512 * 1024, + "no allocation above steady+3MB over 100 flips (%zuKB)", transient / 1024); + CHECK(live_end <= live_steady + 512 * 1024, + "live bytes do not drift up over 100 flips (%zuKB -> %zuKB)", + live_steady / 1024, live_end / 1024); + CHECK(skipped_max == 0, "no page skipped while flipping small pages (max=%d)", skipped_max); + CHECK(mr_reader_ui_failed_pages() == 0, "no permanently failed pages (%d)", + mr_reader_ui_failed_pages()); + /* 取消/预取纪律:100 次翻页的解码次数必须有界(M20 实测曾 1.75× 浪费)。 */ + CHECK(decodes <= 100 * 2, "decode count bounded over 100 flips (%d)", decodes); + meow_view_page_pop(); + pump_ms(200); + } + + /* ---- B6 预算跳过可重试:大页在低预算下占位,MemFree 充足后能解出来 ---- */ + { + mr_reader_ui_test_set_mem(4096, 16000); /* MemFree 4MB → 3MB 常态预算 */ + mr_reader_open_dir(big); + pump_ms(900); + CHECK(mr_reader_ui_skipped_pages() == 1, "over-budget page skipped (skipped=%d)", + mr_reader_ui_skipped_pages()); + CHECK(mr_reader_ui_failed_pages() == 0, "over-budget page is not a permanent failure (failed=%d)", + mr_reader_ui_failed_pages()); + /* MemFree 富余 → 放宽档(峰值上限 = MemFree-4MB,最高 12MB);大页仍应能解出。 */ + mr_reader_ui_test_set_mem(48000, 48000); + tap(meow_ui_width() / 2, meow_ui_height() / 2); + pump_ms(1200); + INFO("relaxed budget=%ldkB skipped=%d resident=%ldkB", mr_reader_ui_peak_budget_kb(), + mr_reader_ui_skipped_pages(), mr_reader_ui_resident_kb()); + CHECK(mr_reader_ui_skipped_pages() == 0, + "over-budget page decodable once MemFree is plenty (skipped=%d)", + mr_reader_ui_skipped_pages()); + meow_view_page_pop(); + pump_ms(200); + } + + /* ---- B7 JPEG:模型不低估 + 3MB 阈值下「哪些 JPEG 会变占位」---- */ + if (have_jpeg) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/page.jpg", jpgbook); + long long model = mr_reader_ui_decode_peak_kb(p, 2, 480, 720); + size_t b0 = g_live; + peak_reset(); + meow_img *im = meow_img_load_file_step(p, 720, 2); + if (im && (meow_img_w(im) != 480 || meow_img_h(im) != 720)) { + meow_img *fit = meow_img_resample(im, 480, 720); + if (fit) { meow_img_free(im); im = fit; } + } + size_t measured = peak_since(b0) / 1024 + 1; + int got = im ? 1 : 0; + if (im) meow_img_free(im); + INFO("jpeg 1000x1500 step=2: model=%lldkB measured=%zukB decoded=%d", model, measured, got); + CHECK(got, "jpeg fixture decodes"); + CHECK(model >= (long long)measured, "jpeg model upper bound (%lldkB < %zukB)", model, measured); + CHECK(model <= (long long)measured * 2, "jpeg model not pessimistic (%lldkB vs %zukB)", + model, measured); + /* 1.5MP(真机崩溃页档位)在 3MB 预算内必须可解:某档 step ≤ cap */ + long long cap = mr_reader_ui_peak_cap_kb(); + long long best = mr_reader_ui_decode_peak_kb(p, 8, 480, 720); + INFO("jpeg 1000x1500 step=8 model=%lldkB cap=%lldkB", best, cap); + CHECK(best <= cap, "1.5MP jpeg fits the 3MB cap at some step (%lldkB > %lldkB)", best, cap); + /* 4MP(手机照片档)在 3MB 预算下不可解 → 必须占位(诚实代价,见报告) */ + snprintf(p, sizeof p, "%s/big4.jpg", jpgbook); + long long big = mr_reader_ui_decode_peak_kb(p, 8, 480, 480); + INFO("jpeg 2000x2000 step=8 model=%lldkB (cap=%lldkB -> %s)", + big, cap, big <= cap ? "decodable" : "placeholder"); + CHECK(big > cap, "4MP jpeg exceeds the 3MB cap even at step=8 (as documented)"); + /* 渐进式:识别 + 峰值模型包含 short 系数面(同样尺寸必须比 baseline 高) */ + snprintf(p, sizeof p, "%s/prog.jpg", jpgbook); + long long prog = mr_reader_ui_decode_peak_kb(p, 2, 480, 720); + INFO("jpeg 1000x1500 progressive step=2 model=%lldkB", prog); + CHECK(prog > model, "progressive jpeg modeled higher than baseline (%lldkB vs %lldkB)", + prog, model); + } else { + printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); + } + + meow_view_page_pop(); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + + rm_rf(root); + rm_rf(data); + + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "reader memory budget checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 96bb0d8..bc23033 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -15,8 +15,12 @@ * 宽恒为视口宽),不是屏长边,所以翻页/滚动热路径只有原生 blit:不做 meow_ui_img_scaled * 的每帧重采样,也不再每帧 malloc 1MB 级临时缓冲。几何变化(fit/缩放/切模式)时按新几何 * 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 - * 内存:单页显示缓存 ≤3MB、整窗 ≤9MB(超限淘汰最远页),解码中间峰值 >12MB 的页直接跳过 - * 并显示占位——页数增长不会线性吃内存(常驻只与解码窗大小有关)。 + * 内存(M17 起;M22 按真机实测重定):单页显示缓存 ≤3MB、整窗常驻 ≤3MB(3 槽,超限淘汰 + * 最远页),单次解码真实峰值常态 ≤3MB —— 超出时 JPEG 先降缩解档位、PNG/BMP 直接占位 + * (占位可重试,不是永久失败)。峰值模型见 decode_peak_bytes():它按 stb 的真实分配算 + * (JPEG 分量面、渐进的 short 系数面、PNG 的源缓冲 + RGBA 转换副本),不再是「把 stb 当 + * 缩解库」的低估式。余量确实充足(MemFree 富余)时允许放宽到 MR_DECODE_PEAK_HARD。 + * 页数增长不会线性吃内存(常驻只与解码窗大小有关)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page @@ -54,9 +58,8 @@ #include #include #include -#include -#define MR_SLOTS 4 /* 页图缓存槽上限(条漫视口±1 ≤4;翻页只用 3) */ +#define MR_SLOTS 3 /* 页图缓存槽上限(M22:4→3 省 ~1.4MB;翻页用 2,条漫视口 2–3) */ #define MR_TOP_BAR_H 56 #define MR_SLIDER_H 40 #define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */ @@ -71,14 +74,21 @@ #define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ #define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ #define MR_TOAST_MS 1400 -#define MR_LOAD_RAW_WARN (8L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ -#define MR_DECODE_PEAK_CAP (12L * 1024 * 1024) /* 单次解码中间缓冲峰值上限(超则跳过,防 OOM 闪退) */ +/* ---- 内存预算(M22,按 R3 Pro II 实测校准:MemTotal 56872kB、空闲 RSS 6044kB、 + * 杀点 RSS≈15.6MB / 当时 MemFree 仅 1.4MB;M20 宿主实测单页解码尖峰 3.4–35MB)---- */ +#define MR_DECODE_PEAK_CAP (3L * 1024 * 1024) /* 常态单次解码峰值上限(真实模型;3MB 硬阈值) */ +#define MR_DECODE_PEAK_HARD (12L * 1024 * 1024) /* 余量充足时的绝对峰值上限(不再超老 12MB 档) */ +#define MR_MEM_FREE_MARGIN_KB (4L * 1024) /* 放宽档:MemFree 必须比峰值多出这么多 */ +#define MR_MEM_FLOOR_KB (6L * 1024) /* 解码前门控:MemAvailable 低于此值不解码 */ +#define MR_PREFETCH_MIN_KB (12L * 1024) /* 预取邻居要求的最低 MemAvailable */ +#define MR_SKIP_RETRY_MS 1500 /* 被预算跳过的页:焦点页冷却这么久后自动重试 */ +#define MR_DECODE_SLACK (64L * 1024) /* 峰值模型余量:结构体/行缓冲/分配器开销(实测 5–6KB) */ #define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */ -#define MR_RESIDENT_CAP (9L * 1024 * 1024) /* 页图缓存总字节上限(超则淘汰最远页) */ +#define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */ #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ #define MR_REQ_MAX 8 -#define MR_RES_MAX 4 +#define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */ /* view on_event 的事件号不在公共头(sdk/meow_ui_internal.h §6.4);本页只用到 SWIPE=3。 */ #define MR_EV_SWIPE 3 @@ -147,20 +157,37 @@ typedef struct page_slot { } page_slot; static page_slot g_slot[MR_SLOTS]; -/* 解码失败页标记:避免对坏图/超大图无限重试,UI 显示「图片无法显示」而不是一直加载中。 */ +/* 解码失败页标记:坏图/无法解析 → 永久显示占位(避免无限重试)。 + * 与「预算跳过」是两回事,见 g_page_skip:后者可重试。 */ static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8]; +/* 预算跳过页标记(M22):内存不足 / 单页峰值超预算 → 先不解码,显示「内存不足」占位。 + * 必须在条件改善后可重试:离开再回来(焦点页变化)、或用户点按显式重试、或冷却期后自动重试。 + * M17 的实现把这类页直接标成永久 failed,用户再也看不到——那等于把内存问题变成功能问题。 */ +static unsigned char g_page_skip[(MR_BOOK_MAX_PAGES + 7) / 8]; +static uint64_t g_skip_ms; /* 最近一次预算跳过的时刻(自动重试节流) */ /* 每页原始像素尺寸缓存(打开时按 count 分配;0,0=未知)。绘制/排版几何只查这里, * 不在每帧去 probe 文件(dir_ops_meta 是 fopen+读头,每帧一次会直接拖垮翻页)。 */ typedef struct page_meta { int w, h; } page_meta; static page_meta *g_meta; -/* 本次解码计划(主线程在 queue_page 里算好,经 page_req 传给 worker;worker 在 - * mr_book_open 前写入这几个静态量,解码回调只读它们 → 后台线程不再读 UI 全局几何)。 */ -static int g_plan_want_w, g_plan_want_h; /* 显示框(几何,未钳制) */ -static int g_plan_long; /* 实际解码长边(受单页字节上限钳制) */ +/* 本次解码计划(主线程在 queue_page 里按显示框算好,经 page_req 传给 worker;worker 在 + * mr_book_open 前写入这些静态量,解码回调只读它们 → 后台线程不再读 UI 全局几何)。 + * want_* = 显示框(几何,slot_ready 的判据);box_* = 字节上限钳制后的落地尺寸 + * (解码结果会精确重采样到 box_*,所以命中页仍是 1 次 blit)。 */ +typedef struct decode_plan { + int want_w, want_h; + int box_w, box_h; +} decode_plan; +static decode_plan g_plan; +static int g_plan_page; /* 本次解码的页号(只用于日志) */ +static int g_decode_skip; /* reader_load_page 的跳过原因(MR_SKIP_*) */ static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */ +#define MR_SKIP_NONE 0 /* 解出来了 */ +#define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */ +#define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */ + static int page_failed(int idx) { if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0; @@ -173,18 +200,53 @@ static void page_mark_failed(int idx) g_page_fail[idx >> 3] |= (unsigned char)(1u << (idx & 7)); } +static int page_skipped(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0; + return (g_page_skip[idx >> 3] >> (idx & 7)) & 1; +} + +static void page_mark_skipped(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return; + g_page_skip[idx >> 3] |= (unsigned char)(1u << (idx & 7)); + g_skip_ms = meow_ui_time_ms(); +} + +static void page_skip_clear(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return; + g_page_skip[idx >> 3] &= (unsigned char)~(1u << (idx & 7)); +} + +/* 该页现在没图、且不会自己再来(永久失败 / 被预算跳过)→ 画占位文字而不是「加载中…」。 + * 两者都是「没图」,但只有 skip 是可重试的(见 maybe_retry_skipped_focus)。 */ +static int page_blocked(int idx) +{ + return page_failed(idx) || page_skipped(idx); +} + +/* 占位文字:坏图是不可恢复的;预算跳过是「内存不足」,点按可重试(任务 2 的可重试语义)。 */ +static const char *page_placeholder(int idx) +{ + if (page_skipped(idx)) return "内存不足,点按重试"; + if (page_failed(idx)) return "图片无法显示"; + return "加载中…"; +} + /* ---- 后台解码队列(请求携带解码几何,结果带回同一条几何) ---- */ typedef struct page_req { int idx; unsigned gen; - int want_w, want_h; /* 按哪个显示框解码(主线程算) */ - int long_side; /* 实际解码长边(≤ 单页字节上限) */ + int want_w, want_h; /* 按哪个显示框解码(主线程算,slot_ready 判据) */ + int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */ } page_req; typedef struct page_res { int idx; unsigned gen; meow_img *img; int fail; + int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; } page_res; @@ -254,92 +316,15 @@ static void cont_save_now(void); static int current_page(void); /* ============================================================ */ -/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ -/* ============================================================ */ -/* 扩展名判断:JPEG 支持解码期缩解(M12),PNG/BMP 只能整幅解码后降采样。 */ -static int path_is_jpeg(const char *path) -{ - const char *dot = path ? strrchr(path, '.') : NULL; - if (!dot) return 0; - dot++; - return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0; -} +/* 内存读数与观测(M22) */ +/* ------------------------------------------------------------ + * /proc/meminfo 与 /proc/self/status 是唯一可用的实时内存事实来源。读不到(宿主/非 Linux) + * 一律返回 -1,调用方必须按「未知」处理,不许猜。宿主测试用 mr_reader_ui_test_set_mem() + * 注入固定读数,让内存门控/放宽档可以确定性验证。 + * ============================================================ */ +static long g_mem_free_override = -1; /* >=0 = 宿主测试注入的 MemFree(kB) */ +static long g_mem_avail_override = -1; /* >=0 = 宿主测试注入的 MemAvailable(kB) */ -/* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit), - * 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。 - * 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */ -static int plan_decode_long(int want_w, int want_h) -{ - int l0 = want_w > want_h ? want_w : want_h; - int s0 = want_w > want_h ? want_h : want_w; - if (l0 <= 0) return 0; - int l = l0 < 64 ? 64 : l0; - while (l > 64) { - int s = (int)((long long)s0 * l / l0); - if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break; - l = l * 3 / 4; - } - return l; -} - -static meow_img *reader_load_page(const char *path, void *ud, int maxdim) -{ - (void)ud; - if (!path || !path[0]) return NULL; - - /* 解码计划由 queue_page() 在主线程算好(want 显示框 + 实际长边),worker/主线程同步兜底 - * 路径在 mr_book_open 前写入;无计划时退回旧预算 maxdim。 */ - int want_w = g_plan_want_w, want_h = g_plan_want_h; - int dim = g_plan_long > 0 ? g_plan_long : maxdim; - - /* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。 - * - 解码中间缓冲峰值超上限 → 跳过(页面显示占位),避免真机被 OOM 杀掉(闪退); - * - 峰值 = 解码缓冲 + 随后的 downscale 输出(≤单页缓存上限),两者会同时驻留; - * - 非缩解格式(PNG/BMP)原始 RGBA 超过告警线 → 记 WARN(残余风险)。 */ - int pw = 0, ph = 0, ch = 0; - int have = meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0; - if (have) { - int l0 = pw > ph ? pw : ph; - long long out_bytes = MR_PAGE_BYTES_CAP; /* downscale/缩解输出上限 */ - if (want_w > 0 && want_h > 0 && (long long)want_w * want_h * 4 < out_bytes) - out_bytes = (long long)want_w * want_h * 4; - long long decode_bytes; - if (path_is_jpeg(path)) { - /* 估 stbi 缩解后的输出(与 SDK 的档位选择同式):仍超上限才跳过。 */ - int s = 1; - while (s < 8 && (l0 / (s * 2)) >= dim) s *= 2; - decode_bytes = (long long)(pw / s) * (ph / s) * 4; - } else { - decode_bytes = (long long)pw * ph * 4; /* 整幅解码后降采样 */ - if (decode_bytes > MR_LOAD_RAW_WARN) - meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s", - pw, ph, decode_bytes / 1024, path); - } - if (decode_bytes + out_bytes > MR_DECODE_PEAK_CAP) { - meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (peak %lldKB > cap): %s", - pw, ph, (decode_bytes + out_bytes) / 1024, path); - return NULL; - } - } - - meow_img *im = meow_img_load_file_dim(path, dim); - if (!im) return NULL; - /* 兜底:M12 之前的格式/未知格式若没被缩到预算内,再降一次(保持驻留纪律)。 */ - if (dim > 0 && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { - meow_img *small = meow_img_downscale(im, dim); - if (small) { meow_img_free(im); im = small; } - } - return im; -} - -static void reader_unload_page(meow_img *img) -{ - meow_img_free(img); -} - -/* ============================================================ */ -/* 内存观测(验收用:进出 reader 对比) */ -/* ============================================================ */ static long mem_kb(const char *path, const char *key) { FILE *f = fopen(path, "r"); @@ -354,6 +339,21 @@ static long mem_kb(const char *path, const char *key) return v; } +/* MemFree(kB)。真机杀点就是「MemFree 见底」:15,600kB 那次只剩 1,404kB。 */ +static long mem_free_kb(void) +{ + if (g_mem_free_override >= 0) return g_mem_free_override; + return mem_kb("/proc/meminfo", "MemFree:"); +} + +/* MemAvailable(kB):含可回收页缓存。注意本机在 MemFree 1.4MB 时仍有 ~26MB, + * 所以它只能当「系统极度缺页」的门控,不能当解码配额(内核不会等回收就杀进程)。 */ +static long mem_avail_kb(void) +{ + if (g_mem_avail_override >= 0) return g_mem_avail_override; + return mem_kb("/proc/meminfo", "MemAvailable:"); +} + static int resident_pages(void) { int n = 0; @@ -370,11 +370,206 @@ static long resident_bytes(void) return b; } +/* 进出阅读器各打一行(任务 5):RSS 看当前占用,VmHWM 看本次进入的峰值—— + * 验收就是「离开时的 hwm 不超过预算」。 */ static void log_mem(const char *tag) { - meow_log(MEOW_LOG_INFO, "reader mem[%s]: avail=%ldKB rss=%ldKB pages=%d img=%ldKB", - tag, mem_kb("/proc/meminfo", "MemAvailable:"), - mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); + meow_log(MEOW_LOG_INFO, "reader mem[%s]: rss=%ldkB hwm=%ldkB memfree=%ldkB memavail=%ldkB pages=%d img=%ldkB", + tag, mem_kb("/proc/self/status", "VmRSS:"), + mem_kb("/proc/self/status", "VmHWM:"), mem_free_kb(), mem_avail_kb(), + resident_pages(), resident_bytes() / 1024); +} + +/* ============================================================ */ +/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ +/* ============================================================ */ +/* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit), + * 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。 + * 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */ +static int plan_decode_long(int want_w, int want_h) +{ + int l0 = want_w > want_h ? want_w : want_h; + int s0 = want_w > want_h ? want_h : want_w; + if (l0 <= 0) return 0; + int l = l0 < 64 ? 64 : l0; + while (l > 64) { + int s = (int)((long long)s0 * l / l0); + if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break; + l = l * 3 / 4; + } + return l; +} + +/* 显示框 → 实际落地尺寸(保持长宽比,受单页字节上限钳制)。 + * 正常情况下 box == want(解码结果就是显示尺寸 → 单次 blit);只有超大屏/超长条漫 + * 才会收缩,此时绘制走缩放路径(M17 已知代价,不再是常态)。 */ +static void plan_box_size(int want_w, int want_h, int *bw, int *bh) +{ + int w = want_w > 0 ? want_w : g_vw; + int h = want_h > 0 ? want_h : g_vh; + if (w <= 0 || h <= 0) { w = h = 1; } + int long0 = w > h ? w : h; + int l = plan_decode_long(w, h); + if (l > 0 && l < long0) { + if (w >= h) { h = (int)((long long)h * l / w); w = l; } + else { w = (int)((long long)w * l / h); h = l; } + if (w < 1) w = 1; + if (h < 1) h = 1; + } + *bw = w; + *bh = h; +} + +/* 真实解码峰值模型(M22)。上一版把 stb 当「缩解库」估 (W/s)(H/s)·4,对 JPEG 低估 3.5–8 倍 + * (M20 宿主实测:2000×3000 baseline +10.1MB、渐进 +23.5MB、1200×1600 PNG +12.9MB)。 + * 这里按 stb 的真实分配逐项相加(与 stb_image.h 的分配点对齐): + * 1) stb 阶段:中间面 planes + 解码输出缓冲 dst(两者并存到解压结束) + * JPEG:planes = 分量面(+渐进系数面),dst = (W/s)(H/s)·4(s>1 时只物化抽取输出) + * PNG :planes = 源像素缓冲,dst = W·H·4(ch==4 时同一缓冲,planes 记 0) + * BMP :planes = 0,dst = W·H·4(stb 直接写 RGBA 目标) + * 2) 精修阶段:dst 超长边上限时再落一张 box 大小的图(dst + finished 并存) + * 3) 落框阶段:档位偏粗时把 finished 重采样到显示框(finished + box 并存) + * 峰值 = 三段的最大值。返回字节数(不是 kB),参数非法返回 0。 */ +static long long decode_peak_bytes(const mr_img_info *in, int step, int box_w, int box_h) +{ + if (!in || in->w <= 0 || in->h <= 0 || box_w <= 0 || box_h <= 0) return 0; + long long W = in->w, H = in->h; + long long box = (long long)box_w * box_h * 4; + long long dst; + if (in->codec == MR_IMG_CODEC_JPEG) { + if (step < 1) step = 1; + dst = ((W + step - 1) / step) * ((H + step - 1) / step) * 4; + } else { + dst = W * H * 4; /* PNG/BMP:无解码期缩放,整幅落地 */ + } + long long peak = in->planes + dst; /* stb 阶段 */ + long long finished = dst > box ? box : dst; /* 精修/落框后的最终图 */ + if (dst + finished > peak) peak = dst + finished; + if (finished + box > peak) peak = finished + box; + return peak + MR_DECODE_SLACK; /* 结构体/行缓冲/分配器开销的实测余量 */ +} + +/* 本次解码的峰值预算: + * - 常态 = MR_DECODE_PEAK_CAP(3MB 硬阈值,覆盖基线 6044 + 常驻 3MB + 峰值 3MB ≤ 13.5MB); + * - 只有 MemFree 真的富余时才放宽(真机杀点由 MemFree 见底触发,不是固定 RSS), + * 放宽上限 = min(MR_DECODE_PEAK_HARD, MemFree - MR_MEM_FREE_MARGIN_KB); + * - 读不到 MemFree → 只用常态预算(不猜)。 */ +static long long decode_peak_budget(long memfree_kb) +{ + long long budget = MR_DECODE_PEAK_CAP; + if (memfree_kb >= 0) { + long long room = (memfree_kb - MR_MEM_FREE_MARGIN_KB) * 1024; + if (room > budget) budget = room; + if (budget > MR_DECODE_PEAK_HARD) budget = MR_DECODE_PEAK_HARD; + } + return budget; +} + +/* 固定字段的解码日志(任务 5):page/src/peak/memfree/memavail 之后是结论, + * 便于 `grep 'reader decode'` 后按列看曲线。 */ +static void log_decode(int page, int src_w, int src_h, long long peak_bytes, + long memfree, long memavail, const char *verdict, int step, + unsigned long long ms) +{ + meow_log(MEOW_LOG_INFO, + "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s step=%d ms=%llu", + page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, step, ms); +} + +static meow_img *reader_load_page(const char *path, void *ud, int maxdim) +{ + (void)ud; + g_decode_skip = MR_SKIP_NONE; + uint64_t t0 = meow_ui_time_ms(); + if (!path || !path[0]) return NULL; + + /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限);worker/主线程同步兜底 + * 路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ + int want_w = g_plan.want_w, want_h = g_plan.want_h; + int box_w = g_plan.box_w, box_h = g_plan.box_h; + int page = g_plan_page; + if (box_w <= 0 || box_h <= 0) { + if (maxdim > 0) { + box_w = box_h = maxdim; + } else { + box_w = want_w > 0 ? want_w : 1; + box_h = want_h > 0 ? want_h : 1; + } + } + int dim = box_w > box_h ? box_w : box_h; + + long mfree = mem_free_kb(); + long mavail = mem_avail_kb(); + + /* 1) 门控(任务 1):MemAvailable 不足 → 不解码、不分配,直接占位(可重试)。 + * 阈值 = MR_MEM_FLOOR_KB。读不到(-1)按「可解码」放行,不因探测失败卡死阅读。 */ + if (mavail >= 0 && mavail < MR_MEM_FLOOR_KB) { + g_decode_skip = MR_SKIP_LOWMEM; + log_decode(page, 0, 0, 0, mfree, mavail, "skip=lowmem", 1, + (unsigned long long)(meow_ui_time_ms() - t0)); + return NULL; + } + + /* 2) 头部探测(零像素内存)→ 真实峰值模型 → 选缩解档位。 */ + mr_img_info in; + int have = mr_probe_info(path, &in); + int step = 1; + long long peak = 0; + if (have) { + long long budget = decode_peak_budget(mfree); + if (in.codec == MR_IMG_CODEC_JPEG) { + /* 从「不欠采样」的档位起步,逐步加粗直到峰值进预算。 + * 分量面与档位无关,所以大页最终仍会无档可用 → 占位(诚实代价)。 */ + int l0 = in.w > in.h ? in.w : in.h; + step = meow_img_pick_scale(l0, dim); + for (;;) { + peak = decode_peak_bytes(&in, step, box_w, box_h); + if (peak <= budget) break; + if (step >= 8) { step = 8; break; } + step *= 2; + } + } else { + step = 1; + peak = decode_peak_bytes(&in, step, box_w, box_h); + } + if (peak > budget) { + g_decode_skip = MR_SKIP_PEAK; + log_decode(page, in.w, in.h, peak, mfree, mavail, "skip=peak", step, + (unsigned long long)(meow_ui_time_ms() - t0)); + return NULL; + } + } + + /* 3) 解码(档位已定)。失败与「跳过」必须分开:NULL + skip==0 才是坏图。 */ + meow_img *im = have ? meow_img_load_file_step(path, dim, step) + : meow_img_load_file_dim(path, dim); + if (!im) { + log_decode(page, have ? in.w : 0, have ? in.h : 0, peak, mfree, mavail, "fail", step, + (unsigned long long)(meow_ui_time_ms() - t0)); + return NULL; + } + /* 兜底:非 JPEG 或粗档位输出没被缩到上限内,再降一次(保持驻留纪律)。 */ + if (dim > 0 && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { + meow_img *small = meow_img_downscale(im, dim); + if (small) { meow_img_free(im); im = small; } + } + /* 4) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit, + * 不会把内存换来的降级变成每帧重采样(M17 的热路径纪律)。 + * 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节, + * 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */ + if (have && step > 1 && (in.w >= box_w || in.h >= box_h) && + (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { + meow_img *fit = meow_img_resample(im, box_w, box_h); + if (fit) { meow_img_free(im); im = fit; } + } + log_decode(page, have ? in.w : 0, have ? in.h : 0, peak, mfree, mavail, "ok", step, + (unsigned long long)(meow_ui_time_ms() - t0)); + return im; +} + +static void reader_unload_page(meow_img *img) +{ + meow_img_free(img); } /* ============================================================ */ @@ -551,7 +746,9 @@ static int slot_ready(int idx) return abs(w - s->want_w) <= MR_SLOT_TOL && abs(h - s->want_h) <= MR_SLOT_TOL; } -/* 淘汰离焦点页最远的一张(不淘汰 focus 与 except)。返回被清掉的槽下标,无则 -1。 */ +/* 淘汰离焦点页最远的一张(不淘汰 focus、可见页与 except)。返回被清掉的槽下标,无则 -1。 + * M22:可见页不淘汰——条漫视口可能同时露出 2–3 页,按距离淘汰会把屏幕上的页拆掉, + * 结果是「一页闪占位 → 重解码 → 再被拆」的抖动。宁可临时超一点字节,也不让可见页消失。 */ static int slot_evict_far(int except) { int focus = focus_page(); @@ -559,6 +756,7 @@ static int slot_evict_far(int except) for (int i = 0; i < MR_SLOTS; i++) { if (i == except || !g_slot[i].img) continue; if (g_slot[i].idx == focus) continue; + if (page_visible(g_slot[i].idx)) continue; int d = abs(g_slot[i].idx - focus); if (d > far_d) { far_d = d; far = i; } } @@ -566,6 +764,17 @@ static int slot_evict_far(int except) return far; } +/* 开新页前先腾地方(任务 3):按「即将落地的单页字节」预判,先淘汰非可见/非焦点的旧图, + * 让解码缓冲不与陈旧常驻叠加。只在逼近上限时才动手,避免把预取邻居立刻扔掉 + *(那样翻页会从「命中」退化成「重新解码」,反而增加峰值次数)。 */ +static void slots_reserve(long need_bytes) +{ + if (need_bytes <= 0) return; + while (resident_bytes() + need_bytes > MR_RESIDENT_CAP) { + if (slot_evict_far(-1) < 0) return; /* 只剩可见页:放行,下一次 slot_put 再收敛 */ + } +} + /* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。 * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */ static int slot_put(int idx, meow_img *img, int want_w, int want_h) @@ -579,12 +788,14 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h) if (i < 0) { if (empty >= 0) { i = empty; - } else { /* 全满:替换离焦点页最远的一张 */ + } else { /* 全满:替换离焦点页最远、且不可见的一张 */ int focus = focus_page(), far_d = -1; for (int k = 0; k < MR_SLOTS; k++) { + if (page_visible(g_slot[k].idx)) continue; int d = abs(g_slot[k].idx - focus); if (d > far_d) { far_d = d; i = k; } } + if (i < 0) i = g_slot[0].img ? 0 : -1; /* 全可见:只能挤掉第一槽(极端条漫页) */ } } if (i < 0) { meow_img_free(img); return 0; } @@ -666,15 +877,30 @@ static int req_queued(int idx) return 0; } +/* 预取闸门(任务 4):拖动/惯性期间只解落点页,不做邻居预取;且只在内存确实富裕时预取。 + * MemAvailable 读不到按「不预取」处理(保守)。 */ +static int prefetch_allowed(void) +{ + if (g_dragging) return 0; + if (g_scroll && meow_scroll_active(g_scroll)) return 0; + long a = mem_avail_kb(); + if (a < 0) return 0; + return a >= MR_PREFETCH_MIN_KB; +} + static void queue_page(int idx) { if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - if (slot_ready(idx) || page_failed(idx)) return; + /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ + if (slot_ready(idx) || page_blocked(idx)) return; /* 几何在这里(主线程)算好塞进请求:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 */ int want_w = 0, want_h = 0; page_geometry_at(idx, &want_w, &want_h); if (want_w <= 0 || want_h <= 0) { want_w = g_vw; want_h = g_vh; } - int long_side = plan_decode_long(want_w, want_h); + int box_w = 0, box_h = 0; + plan_box_size(want_w, want_h, &box_w, &box_h); + /* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */ + slots_reserve((long)box_w * box_h * 4); pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { page_req *r = &g_req[g_req_tail]; @@ -682,7 +908,8 @@ static void queue_page(int idx) r->gen = g_gen; r->want_w = want_w; r->want_h = want_h; - r->long_side = long_side; + r->box_w = box_w; + r->box_h = box_h; g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; pthread_cond_signal(&g_cv); @@ -701,32 +928,40 @@ static void *reader_worker(void *arg) req = g_req[g_req_head]; g_req_head = (g_req_head + 1) % MR_REQ_MAX; g_req_n--; + /* 取消(任务 4 / M20 P0-3):出队后、mr_book_open 前二次判过期——旧册(gen 变) + * 或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 1.75× 解码, + * 全是这种在飞不可取消的请求。锁内判,窗口也在锁内写,避免共享状态竞争。 */ + int stale = (req.gen != g_gen) || !in_window(req.idx); pthread_mutex_unlock(&g_lock); + if (stale) { + meow_log(MEOW_LOG_INFO, + "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0", + req.idx, mem_free_kb(), mem_avail_kb()); + continue; + } - /* 解码计划:本线程私有(单 worker),解码回调只读这三个量。 */ - g_plan_want_w = req.want_w; - g_plan_want_h = req.want_h; - g_plan_long = req.long_side; - uint64_t t0 = meow_ui_time_ms(); + /* 解码计划:本线程私有(单 worker),解码回调只读这几个量。 */ + g_plan.want_w = req.want_w; + g_plan.want_h = req.want_h; + g_plan.box_w = req.box_w; + g_plan.box_h = req.box_h; + g_plan_page = req.idx; + g_decode_skip = MR_SKIP_NONE; meow_img *img = mr_book_open(&g_book, req.idx); - uint64_t dt = meow_ui_time_ms() - t0; g_decode_count++; - int w = img ? meow_img_w(img) : 0; - int h = img ? meow_img_h(img) : 0; - meow_log(MEOW_LOG_INFO, "reader decode page=%d want=%dx%d long=%d ms=%llu %dx%d %s", - req.idx, req.want_w, req.want_h, req.long_side, - (unsigned long long)dt, w, h, img ? "ok" : "fail"); + int skip = img ? MR_SKIP_NONE : g_decode_skip; pthread_mutex_lock(&g_lock); if (!g_stop && g_res_n < MR_RES_MAX) { g_res[g_res_n].idx = req.idx; g_res[g_res_n].gen = req.gen; g_res[g_res_n].img = img; - g_res[g_res_n].fail = img ? 0 : 1; + g_res[g_res_n].fail = (img || skip) ? 0 : 1; + g_res[g_res_n].skip = skip; g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; g_res_n++; - img = NULL; /* 所有权已移交(失败时本就是 NULL) */ + img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); @@ -781,7 +1016,8 @@ static void queue_prune(void) pthread_mutex_unlock(&g_lock); } -/* 收取后台结果:只留窗口内新页。返回 1=有图落位。 */ +/* 收取后台结果:只留窗口内新页。返回 1=有图落位。 + * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 */ static int results_drain(void) { page_res got[MR_RES_MAX]; @@ -795,6 +1031,12 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } + if (got[i].skip) { + /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ + page_mark_skipped(got[i].idx); + if (page_visible(got[i].idx)) g_fail_paint = 1; + continue; + } if (got[i].fail) { page_mark_failed(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; @@ -803,6 +1045,7 @@ static int results_drain(void) if (in_window(got[i].idx)) { if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h) && page_visible(got[i].idx)) changed = 1; + page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ } else { meow_img_free(got[i].img); } @@ -810,14 +1053,40 @@ static int results_drain(void) return changed; } -/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ +/* 预算跳过的页如何重试(任务 2 的可重试语义):只对**焦点页**重试,且冷却 MR_SKIP_RETRY_MS, + * 这样「内存暂时不够」不会变成永久占位,也不会变成每 tick 一次的重试风暴。 + * force=1 是用户显式重试(点按占位页)。 */ +static void maybe_retry_skipped_focus(int force) +{ + int f = focus_page(); + if (f < 0 || !page_skipped(f) || page_failed(f)) return; + if (!force) { + uint64_t now = meow_ui_time_ms(); + if (now - g_skip_ms < MR_SKIP_RETRY_MS) return; + } + page_skip_clear(f); + g_skip_ms = meow_ui_time_ms(); + meow_log(MEOW_LOG_INFO, "reader decode retry page=%d%s", f + 1, force ? " (tap)" : ""); +} + +/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 + * 窗口本身在锁内写(worker 的取消判据要读它)。 */ static void window_update(void) { + pthread_mutex_lock(&g_lock); window_compute(); + pthread_mutex_unlock(&g_lock); /* 不主动清窗口外的图:多留一页「回看缓存」,避免 0→1→0 来回翻时反复重解同一页。 * 容量仍由 MR_SLOTS 与 slot_put 的字节淘汰兜底(最多 MR_SLOTS 张)。 */ queue_prune(); - for (int i = 0; i < g_win_n; i++) queue_page(g_win_idx[i]); + /* 回到某页 → 允许重试这一页(「离开再回来」的可重试语义)。 */ + maybe_retry_skipped_focus(0); + /* 队列第一位 = 跳页目标或焦点页(必解);其余是预取,按内存闸门决定做不做。 */ + int prefetch = prefetch_allowed(); + for (int i = 0; i < g_win_n; i++) { + if (i > 0 && !prefetch) continue; + queue_page(g_win_idx[i]); + } } /* ============================================================ */ @@ -1005,7 +1274,7 @@ static void draw_strip(const meow_theme *th) meow_ui_fill_rect(0, y0, g_vw, y1 - y0, th->surfaceContainer); if (y1 - y0 >= 48) meow_ui_text_center(g_vw / 2, y0 + (y1 - y0) / 2 - 10, - page_failed(i) ? "图片无法显示" : "加载中…", + page_placeholder(i), th->font_body, th->onSurfaceVariant); } } @@ -1069,7 +1338,7 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) draw_page_at(g_page, 0, 0); if (!slot_find(g_page)) { meow_ui_text_center(w / 2, h / 2, - page_failed(g_page) ? "图片无法显示" : "加载中…", + page_placeholder(g_page), th->font_body, th->onSurfaceVariant); } draw_toast(th); @@ -1316,7 +1585,8 @@ static void jump_land(int page, int timed_out) meow_log(MEOW_LOG_INFO, "reader jump land page=%d/%d%s", page + 1, g_count, timed_out ? " (timeout)" : ""); if (timed_out) toast("页面解码较慢,已跳到目标页"); - else if (page_failed(page)) toast("目标页无法显示"); + else if (page_blocked(page)) + toast(page_skipped(page) ? "目标页内存不足,已跳过" : "目标页无法显示"); } static void jump_commit_from_slider(void) @@ -1430,6 +1700,8 @@ static void open_book_reset_state(void) g_win_n = 0; g_fail_paint = 0; memset(g_page_fail, 0, sizeof g_page_fail); + memset(g_page_skip, 0, sizeof g_page_skip); + g_skip_ms = 0; g_jump_open = 0; mr_jump_cancel(&g_jump); g_toast[0] = '\0'; @@ -1525,9 +1797,13 @@ static int open_book(const char *dir, int start_page, int mode) int ww = 0, wh = 0; page_geometry_at(g_page, &ww, &wh); if (ww <= 0 || wh <= 0) { ww = g_vw; wh = g_vh; } - g_plan_want_w = ww; - g_plan_want_h = wh; - g_plan_long = plan_decode_long(ww, wh); + int bw = ww, bh = wh; + plan_box_size(ww, wh, &bw, &bh); + g_plan.want_w = ww; + g_plan.want_h = wh; + g_plan.box_w = bw; + g_plan.box_h = bh; + g_plan_page = g_page; meow_img *first = mr_book_open(&g_book, g_page); if (first) slot_put(g_page, first, ww, wh); meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); @@ -1776,6 +2052,18 @@ static void tap_content(int x, int y) (void)y; if (g_count <= 0) return; int zone = mr_nav_tap_zone(x, g_vw); + /* 当前页被预算跳过(内存不足占位)→ 中间区点按就是显式重试(任务 2:不能永久 failed)。 + * 只占中间区:左右 1/3 仍必须能翻页,否则占位页会把读者困在这一页。 */ + if (zone == MR_ZONE_MIDDLE) { + int f = focus_page(); + if (f >= 0 && page_skipped(f) && !page_failed(f)) { + maybe_retry_skipped_focus(1); + toast("正在重试…"); + window_update(); + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } + } if (zone == MR_ZONE_MIDDLE) { set_chrome(!g_chrome); return; @@ -1929,7 +2217,7 @@ static void reader_tick(void) if (mr_jump_active(&g_jump)) { int target = mr_jump_target(&g_jump); - int have = target >= 0 && (slot_find(target) != NULL || page_failed(target)); + int have = target >= 0 && (slot_find(target) != NULL || page_blocked(target)); int timed_out = 0; if (mr_jump_poll(&g_jump, have, now, MR_JUMP_TIMEOUT_MS, &timed_out)) jump_land(target, timed_out); @@ -2064,7 +2352,9 @@ static void reader_state_reset(void) free(g_meta); g_meta = NULL; g_start_at = MR_READER_START_RESUME; - g_plan_want_w = g_plan_want_h = g_plan_long = 0; + memset(&g_plan, 0, sizeof g_plan); + g_plan_page = 0; + g_decode_skip = MR_SKIP_NONE; } static void reader_page_enter(meow_view *v, void *ctx) @@ -2250,6 +2540,28 @@ int mr_reader_ui_failed_pages(void) if (page_failed(i)) n++; return n; } +int mr_reader_ui_skipped_pages(void) +{ + int n = 0; + for (int i = 0; i < g_count && i < MR_BOOK_MAX_PAGES; i++) + if (page_skipped(i) && !page_failed(i)) n++; + return n; +} +void mr_reader_ui_test_set_mem(long free_kb, long avail_kb) +{ + g_mem_free_override = free_kb; + g_mem_avail_override = avail_kb; +} +/* 峰值模型自检(只读):用真代码的模型算某个文件的预估峰值,供宿主测试与实测对照。 */ +long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int box_h) +{ + mr_img_info in; + if (!path || !mr_probe_info(path, &in)) return 0; + return decode_peak_bytes(&in, step, box_w, box_h) / 1024; +} +long mr_reader_ui_peak_cap_kb(void) { return (long)(MR_DECODE_PEAK_CAP / 1024); } +long mr_reader_ui_peak_budget_kb(void) { return (long)(decode_peak_budget(mem_free_kb()) / 1024); } +long mr_reader_ui_resident_cap_kb(void) { return (long)(MR_RESIDENT_CAP / 1024); } int mr_reader_ui_blit_draws(void) { return g_blit_draws; } int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } @@ -2266,3 +2578,4 @@ void mr_reader_test_set_root(const char *root) } const char *mr_reader_test_root(void) { return g_root; } #endif + diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index e7562a0..3ddca22 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -38,7 +38,16 @@ int mr_reader_ui_count(void); /* 当前册页数 */ int mr_reader_ui_jump_open(void); /* 1=跳页滑块已展开 */ int mr_reader_ui_jump_pending(void);/* 1=跳页目标等待解码 */ long mr_reader_ui_resident_kb(void);/* 驻留页图字节(KB) */ -int mr_reader_ui_failed_pages(void);/* 解码失败/超预算被跳过的页数 */ +int mr_reader_ui_failed_pages(void);/* 解码失败(坏图)页数 */ +int mr_reader_ui_skipped_pages(void);/* 被内存预算跳过(可重试)的页数 */ +/* 注入 /proc/meminfo 读数(kB);-1 = 恢复真实读取。用于确定性验证内存门控与放宽档。 */ +void mr_reader_ui_test_set_mem(long free_kb, long avail_kb); +/* 峰值模型自检:用产品代码里的同一个模型算预估峰值(kB),供宿主测试与 --wrap malloc + * 实测对照。0=探测失败。 */ +long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int box_h); +long mr_reader_ui_peak_cap_kb(void); /* 常态峰值上限(kB) */ +long mr_reader_ui_peak_budget_kb(void); /* 当前 MemFree 下的实际预算(kB) */ +long mr_reader_ui_resident_cap_kb(void); /* 常驻上限(kB) */ int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ From d84dd949c78069b8160e524192df10803e6e6bd0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 21:07:59 +0800 Subject: [PATCH 23/38] =?UTF-8?q?SDK:=20=E5=9B=BE=E7=89=87=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E7=9C=9F=E7=BC=A9=E8=A7=A3=E5=90=8E=E7=AB=AF=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20JPEG=20DCT=20=E5=9F=9F=20/=20PNG=20?= =?UTF-8?q?=E9=80=90=E8=A1=8C=20/=20BMP=20=E8=B7=B3=E8=A1=8C=EF=BC=88M23?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stb 的 stbi_load_scaled 是「先整幅解、再重采样」:2000x3000 baseline 峰值 10.07MB、 渐进式 25.84MB、1600x2400 PNG 25.65MB,真机解码余量只有 ~4.2MB。新增解码后端在 解码期只算目标分辨率,峰值只与输出尺寸相关: - meow_jpeg.c:自写纯 C JPEG 解码(baseline + progressive,Huffman)。按 ceil(8*maxdim/长边) 选 N 点 DCT 缩解(N=1..7),基函数为「8 点 IDCT 按像素区间 盒式平均」的 Q12 定点(DC 恒为 F00/8,N 之间亮度一致);系数边解边累加进分量平面, 不留整幅系数阵列;渐进式跳过精细化扫描(Ah>0)、只解各频带首次扫描 —— 渐进式与 baseline 内存足迹相同(这是 libjpeg 系列做不到的:它对渐进式必须整幅驻留 JBLOCK)。 不支持项(4 分量 CMYK/YCCK、算术/无损/分层、12bit 精度)返回 0 回退 stb。 - meow_png_stream.c:libz 逐行 inflate + 反滤波,只把需要的列写进目标行; gray/gray+A/RGB/RGBA/调色板,1/2/4/8/16bit(16→8 取高字节);Adam7 等回退 stb。 BMP 按目标行 seek + 行内跳列(8bpp 调色板/16bpp RGB555/24/32bpp),不读全图。 - meow_ui_img.c:load_file_dim / load_mem_dim 先进后端,失败/不支持回落 stb 旧路径 (stbi_load_scaled + 精修);输出尺寸与 meow_img_downscale 同公式,对外语义不变。 宿主实测(--wrap malloc,maxdim=720,M23 前后同一夹具): 2000x3000 baseline 10.07 → 1.86 MiB 2000x3000 progressive 25.84 → 1.86 MiB 1600x2400 PNG 25.65 → 1.33 MiB 1600x2400 BMP 15.97 → 1.32 MiB 4MP/8MP/12MP JPEG 9.67/13.37/20.12 → 2.30/2.20/2.29 MiB(不随分辨率线性增长) sdk/test_meow_img_stream.c:峰值断言(≤2.5MiB)、与 stb 整幅+降采样的像素比对 (平均差 4.4-13.7/255,块均值结构相关 ≥0.88)、内存入口逐字节一致、回退矩阵 (CMYK/1bpp BMP/Adam7 PNG)、restart marker、缩解阶扫描 N=4/5/7、坏图容错。 build_sdk.sh 增加 meow_jpeg / meow_png_stream 两个 TU(MIPS32 -Os 零警告)。 体积:目标码 +24.9KB(meow_ui.a +25.5KB),picbrowser 静态二进制 +18.2KB。 --- sdk/build_sdk.sh | 2 +- sdk/include/meow_ui_image.h | 19 +- sdk/meow_jpeg.c | 974 ++++++++++++++++++++++++++++++++++++ sdk/meow_jpeg.h | 48 ++ sdk/meow_png_stream.c | 492 ++++++++++++++++++ sdk/meow_ui_img.c | 22 +- sdk/test_meow_img_budget.c | 16 +- sdk/test_meow_img_stream.c | 762 ++++++++++++++++++++++++++++ 8 files changed, 2320 insertions(+), 15 deletions(-) create mode 100644 sdk/meow_jpeg.c create mode 100644 sdk/meow_jpeg.h create mode 100644 sdk/meow_png_stream.c create mode 100644 sdk/test_meow_img_stream.c diff --git a/sdk/build_sdk.sh b/sdk/build_sdk.sh index d03749f..de53531 100755 --- a/sdk/build_sdk.sh +++ b/sdk/build_sdk.sh @@ -23,7 +23,7 @@ rm -f meow_ui.a *.o # select/net_ipc)随主仓增量并入。 # meow_ui_time.c 必须**编进归档**且排在前面:它提供 meow_ui_time_ms(gfx 已不再实现)与 # __clock_gettime* 强定义,归档成员随 meow_ui_time_ms 的引用被拉入,从而在 -lc 之前解析。 -OBJS="meow_ui_time meow_ui_gfx meow_ui_log meow_ui_notify meow_notify_ipc meow_ui_img meow_ui_widget meow_ui_input meow_ui_theme meow_ui_ime meow_pinyin meow_ui_sys meow_ui_vol meow_ui_activity meow_ui_global meow_ui_dialog meow_ui_config meow_ui_draw meow_ui_view meow_ui_nav meow_ui_media meow_ui_audio stb_image meow_font meow_ui_box meow_ui_anim meow_ui_scroll meow_ui_str meow_cachegov meow_lazygrid meow_select meow_net_ipc" +OBJS="meow_ui_time meow_ui_gfx meow_ui_log meow_ui_notify meow_notify_ipc meow_ui_img meow_jpeg meow_png_stream meow_ui_widget meow_ui_input meow_ui_theme meow_ui_ime meow_pinyin meow_ui_sys meow_ui_vol meow_ui_activity meow_ui_global meow_ui_dialog meow_ui_config meow_ui_draw meow_ui_view meow_ui_nav meow_ui_media meow_ui_audio stb_image meow_font meow_ui_box meow_ui_anim meow_ui_scroll meow_ui_str meow_cachegov meow_lazygrid meow_select meow_net_ipc" CFLAGS="-Os -static -Iinclude -I." for f in $OBJS; do echo "== cc $f ==" diff --git a/sdk/include/meow_ui_image.h b/sdk/include/meow_ui_image.h index 7cde639..8d3a642 100644 --- a/sdk/include/meow_ui_image.h +++ b/sdk/include/meow_ui_image.h @@ -1,7 +1,8 @@ /* * meow_ui_image.h — SDK 图片层(§4,B1)。 * - * 原 meow_ui.h 拆分的一部分(image 模块)。内部 stb_image(单头文件,静态编入 meow_ui.a) + * 原 meow_ui.h 拆分的一部分(image 模块)。解码后端:M23 真缩解(JPEG DCT 域 / PNG 逐行 / + * BMP 跳行,见 meow_jpeg.h)优先,回退 stb_image(单头文件,静态编入 meow_ui.a)。 * 解 PNG/JPEG/BMP,统一转 RGBA。meow_img 为不透明句柄(前向声明见 meow_ui_types.h)。 */ #ifndef MEOW_UI_IMAGE_H @@ -26,10 +27,18 @@ void meow_img_free(meow_img *img); * App 可用它在加载前判断「图太大」并给用户提示,而不必先撞 OOM。 */ int meow_img_probe_file(const char *path, int *w, int *h, int *channels); -/* 追加(M12)预算解码:解码期受长边约束,返回图最长边 ≤ maxdim。 - * JPEG:按 1/2、1/4、1/8 整数步长抽取解码(stbi_load_scaled),整幅原尺寸 RGBA 缓冲从不物化, - * 峰值 ≈ 亮度分量平面 + 缩解输出 + 单行缓冲;随后用 meow_img_downscale 精修到 maxdim。 - * PNG/BMP 等 stb 无解码期缩放能力的格式:整幅解码后立即降采样(峰值同旧入口,见 M12 报告)。 +/* 追加(M12/M23)预算解码:解码期受长边约束,返回图最长边 ≤ maxdim,尺寸与 + * 「meow_img_load_file + meow_img_downscale(maxdim)」逐字节同口径(公式一致)。 + * + * 实现(M23 起):先进真缩解后端(内部 meow_jpeg.c / meow_png_stream.c),解码期只算目标分辨率: + * JPEG:DCT 域缩解(每 8×8 块只解出 N×N 像素,N 由 8·maxdim/长边 取「不欠采样」的最小值), + * 渐进式只解到首个精细化扫描(首个 Ah>0)——低频带已完整,不再整幅驻留系数阵列; + * PNG :libz 逐行 inflate + 反滤波,按步长丢行丢列直写目标尺寸(gray/gray+A/RGB/RGBA/ + * 调色板/16bit,16bit 取高字节); + * BMP :按目标行 seek 到源行 + 行内跳列(8bpp 调色板 / 16bpp RGB555 / 24bpp / 32bpp)。 + * 峰值只与输出尺寸相关,不随源图分辨率/压缩体积线性增长(2000×3000 页 ≤2.5MB 量级)。 + * 不支持的特性(CMYK/YCCK 4 分量 JPEG、算术编码、Adam7 交错 PNG、RLE/1-4bpp BMP、几乎等尺寸) + * 自动回退 stb 旧路径(meow_img_load_file + 缩解/降采样),对外语义完全不变。 * 小图(长边 ≤ maxdim)走旧路径,与原 API 完全一致;maxdim<=0 等价旧入口。失败返回 NULL。 */ meow_img *meow_img_load_file_dim(const char *path, int maxdim); meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim); diff --git a/sdk/meow_jpeg.c b/sdk/meow_jpeg.c new file mode 100644 index 0000000..4b05372 --- /dev/null +++ b/sdk/meow_jpeg.c @@ -0,0 +1,974 @@ +/* + * meow_jpeg.c — M23 真缩解后端(一):JPEG DCT 域缩解 + 格式分派。 + * + * 背景(M20 审计实测):stb 的 stbi_load_scaled 是「先整幅解、再重采样」——baseline 分量面 + * 1.5·W·H、渐进式再加 3·W·H,2000×3000 baseline 峰值 +10.1MB、渐进 +23.5MB,真机 kill 点 + * 只剩 ~4.2MB 余量。本文件换成「解码期只算目标分辨率」: + * + * - 按目标显示尺寸选 DCT 缩解档 N ∈ {1,2,4}(1/8、1/4、1/2 分辨率),每 8×8 块只解出 + * N×N 个像素——N 点基函数是「8 点 IDCT 按像素区间盒式平均」的定点近似,DC 项恒为 F00/8, + * 与「整幅解完再降采样」结构一致(差异只在高频细节)。 + * - 系数边解边累加进分量平面,不留整幅系数阵列:baseline/渐进式内存足迹相同。 + * - 渐进式只解到首个精细化扫描(首个 Ah>0)为止:此时各频带的首次扫描(Ah=0)已到齐, + * 低频信息完整;再往后只是同一批系数的量化精度细化。因此**不需要**整幅系数的 + * successive-approximation 缓存——这正是 libjpeg 系列做不到 2.5MB 的根因 + * (渐进式必须整幅驻留 JBLOCK,≈1 字节/源像素/分量)。 + * - 输出直接是目标尺寸 RGBA,不再「缩解结果 + 精修输出」两份同时驻留。 + * + * 不支持 → 返回 0 让 meow_ui_img.c 回退 stb(不改变对外语义):4 分量 CMYK/YCCK、 + * 算术编码(SOF9/10)、无损(SOF3)、分层(SOF5-7,13-15)、12bit 精度。 + * + * 许可证:MeowOS 自写(MIT,随 SDK 发布);未引入第三方解码器源码。 + * 参考资料:ITU-T T.81 (JPEG) 语法与 Huffman 解码、libjpeg 的 marker/DQT/DHT 布局; + * 缩解基函数见文件内 mj_basis_* 的推导注释。 + */ +#include "meow_jpeg.h" + +#include +#include +#include +#include + +/* ================================================================== */ +/* 公共:目标尺寸(与 meow_img_downscale 同一公式,保证对外表现不变) */ +/* ================================================================== */ +void meow_img_target_dims(int w, int h, int maxdim, int *tw, int *th) +{ + if (w >= h) { + *tw = maxdim; + *th = (int)((long)h * maxdim / w); + } else { + *th = maxdim; + *tw = (int)((long)w * maxdim / h); + } + if (*tw < 1) *tw = 1; + if (*th < 1) *th = 1; +} + +/* ================================================================== */ +/* 字节源:文件流(4KB 环形读)或内存(零拷贝,直接用调用方缓冲) */ +/* ================================================================== */ +#define MJ_IOBUF 4096 + +typedef struct { + FILE *f; + const unsigned char *mem; + size_t memlen, mempos; + unsigned char buf[MJ_IOBUF]; + int buflen, bufpos; + unsigned int bitbuf; /* 熵流位缓冲(MSB 在前) */ + int bitcnt; + int over; /* 本熵段已到尽头(遇到 marker 或 EOF) */ + int marker; /* over 时已吃掉的 marker 码(0=无) */ +} mj_src; + +static int mj_raw(mj_src *s) +{ + if (s->mem) { + if (s->mempos >= s->memlen) return -1; + return s->mem[s->mempos++]; + } + if (s->bufpos >= s->buflen) { + s->buflen = (int)fread(s->buf, 1, MJ_IOBUF, s->f); + s->bufpos = 0; + if (s->buflen <= 0) { s->buflen = 0; return -1; } + } + return s->buf[s->bufpos++]; +} + +static int mj_u16(mj_src *s) +{ + int a = mj_raw(s), b = mj_raw(s); + return (a < 0 || b < 0) ? -1 : ((a << 8) | b); +} + +static int mj_read(mj_src *s, unsigned char *dst, size_t n) +{ + while (n--) { + int c = mj_raw(s); + if (c < 0) return 0; + *dst++ = (unsigned char)c; + } + return 1; +} + +static int mj_skip(mj_src *s, long n) +{ + while (n-- > 0) if (mj_raw(s) < 0) return 0; + return 1; +} + +/* 熵段内的字节:FF00 → FF;遇到真 marker(含 RSTn)→ 置 over 并记录,返回 0。 */ +static int mj_eb(mj_src *s) +{ + int c; + if (s->over) return 0; + c = mj_raw(s); + if (c < 0) { s->over = 1; return 0; } + if (c != 0xFF) return c; + for (;;) { + int d = mj_raw(s); + if (d < 0) { s->over = 1; return 0; } + if (d == 0x00) return 0xFF; + if (d == 0xFF) continue; /* 填充 FF */ + s->marker = d; s->over = 1; return 0; + } +} + +static void mj_fill(mj_src *s, int n) +{ + while (s->bitcnt < n) { + s->bitbuf = (s->bitbuf << 8) | (unsigned)mj_eb(s); + s->bitcnt += 8; + } +} + +static int mj_getbits(mj_src *s, int n) +{ + if (n <= 0) return 0; + if (s->bitcnt < n) mj_fill(s, n); + s->bitcnt -= n; + return (int)((s->bitbuf >> s->bitcnt) & ((1u << n) - 1u)); +} + +static int mj_extend(int v, int n) +{ + return v < (1 << (n - 1)) ? v - (1 << n) + 1 : v; +} + +/* ================================================================== */ +/* Huffman 表:9 位快表 + 慢路径(最长码 16 位) */ +/* ================================================================== */ +typedef struct { + unsigned char bits[17]; /* 各码长符号数 */ + unsigned char vals[256]; + unsigned short fast[512]; /* (len<<8)|sym,0 = 走慢路径 */ + int maxcode[17]; + int mincode[17]; + int valptr[17]; +} mj_huff; + +static void mj_huff_build(mj_huff *h) +{ + int code = 0, k = 0, l; + memset(h->fast, 0, sizeof h->fast); + h->maxcode[0] = -1; + for (l = 1; l <= 16; l++) { + int i; + h->valptr[l] = k; + h->mincode[l] = code; + for (i = 0; i < h->bits[l]; i++) { + if (l <= 9) { + int shift = 9 - l, j; + unsigned short e = (unsigned short)((l << 8) | h->vals[k]); + for (j = 0; j < (1 << shift); j++) + h->fast[(code << shift) | j] = e; + } + code++; + k++; + } + h->maxcode[l] = h->bits[l] ? code - 1 : -1; + code <<= 1; + } +} + +static int mj_hdecode(mj_src *s, const mj_huff *h) +{ + if (s->bitcnt < 9) mj_fill(s, 9); + if (s->bitcnt >= 9) { + unsigned idx = (s->bitbuf >> (s->bitcnt - 9)) & 511u; + unsigned e = h->fast[idx]; + if (e) { + s->bitcnt -= (int)(e >> 8); + return (int)(e & 0xFFu); + } + } + { + int code = 0, l; + for (l = 1; l <= 16; l++) { + code = (code << 1) | mj_getbits(s, 1); + if (code <= h->maxcode[l]) + return h->vals[h->valptr[l] + code - h->mincode[l]]; + if (s->over) return 0; + } + } + s->over = 1; + return 0; +} + +/* zigzag 序号 → 自然序(行优先 v*8+u) */ +static const unsigned char mj_zigzag[64] = { + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63 +}; + +/* ================================================================== */ +/* 缩解基(Q12):B(u,x) = 「8 点 IDCT 在输出像素区间的盒式平均」 */ +/* B(u,y) = (N/8)·Σ_{x} w(y,x)·(1/2)·C(u)·cos((2x+1)uπ/16), C(0)=1/√2 */ +/* w(y,x) = 像素 x 的单位区间与输出区间 [y·8/N, (y+1)·8/N) 的重叠长度 */ +/* 于是:DC 单频重建恒为 F00/8(与 8×8 IDCT 的均值一致);N=8 时退化为 */ +/* 精确 IDCT;任意 N 之间的亮度/对比度一致(同一套离散平均口径)。 */ +/* 只列 N=1..7:N=8(≈不缩解)交回 stb 旧路径,保持「档位」对外表现。 */ +/* ================================================================== */ +static const short mj_basis_1[1] = { 1448 }; +static const short mj_basis_2[4] = { + 1448, 1448, + 1312, -1312 +}; +static const short mj_basis_3[9] = { + 1448, 1448, 1448, + 1676, 0, -1676, + 808, -1615, 808 +}; +static const short mj_basis_4[16] = { + 1448, 1448, 1448, 1448, + 1856, 769, -769, -1856, + 1338, -1338, -1338, 1338, + 652, -1573, 1573, -652 +}; +static const short mj_basis_5[25] = { + 1448, 1448, 1448, 1448, 1448, + 1894, 1187, 0, -1187, -1894, + 1476, -530, -1892, -530, 1476, + 914, -1498, 0, 1498, -914, + 362, -1086, 1448, -1086, 362 +}; +static const short mj_basis_6[36] = { + 1448, 1448, 1448, 1448, 1448, 1448, + 1932, 1420, 584, -584, -1420, -1932, + 1615, 0, -1615, -1615, 0, 1615, + 1177, -1204, -1356, 1356, 1204, -1177, + 724, -1448, 724, 724, -1448, 724, + 351, -805, 1377, -1377, 805, -351 +}; +static const short mj_basis_7[49] = { + 1448, 1448, 1448, 1448, 1448, 1448, 1448, + 1970, 1562, 861, 0, -861, -1562, -1970, + 1754, 392, -1199, -1892, -1199, 392, 1754, + 1440, -802, -1682, 0, 1682, 802, -1440, + 1086, -1448, -362, 1448, -362, -1448, 1086, + 745, -1407, 888, 0, -888, 1407, -745, + 449, -946, 889, -784, 889, -946, 449 +}; + +static const short *mj_basis(int n) +{ + switch (n) { + case 1: return mj_basis_1; + case 2: return mj_basis_2; + case 3: return mj_basis_3; + case 4: return mj_basis_4; + case 5: return mj_basis_5; + case 6: return mj_basis_6; + default: return mj_basis_7; + } +} + +/* ================================================================== */ +/* 分量与图像 */ +/* ================================================================== */ +typedef struct { + int id, h, v, tq; + int n; /* 缩解阶:每块 n×n 像素 */ + int pw, ph; /* 平面(含 MCU padding)尺寸 */ + int vw, vh; /* 有效像素尺寸(最近邻采样用) */ + int ow, oh; /* 本分量自己的块网格(非交织扫描用) */ + int td, ta; /* 当前扫描的 Huffman 表号 */ + int pred; /* DC 预测值(量化域) */ + unsigned char *plane; +} mj_comp; + +typedef struct { + int width, height; + int ncomp; + int hmax, vmax; + int mcus_x, mcus_y; + int progressive; + int rst; /* 重启间隔(MCU 数),0 = 无 */ + int adobe_transform; /* -1 未声明;0=RGB/CMYK;1=YCbCr */ + int blocks; /* 已解块数(判「有没有解出东西」) */ + mj_comp c[3]; + int qt[4][64]; + int have_qt[4]; + mj_huff dc[4], ac[4]; + int have_dc[4], have_ac[4]; + mj_src src; +} mj_img; + +#define MJ_PEAK_CAP (8u * 1024u * 1024u) /* 防御性上限:异常巨图不至于一次吃掉几十 MB */ + +static void mj_free(mj_img *img) +{ + int i; + for (i = 0; i < img->ncomp; i++) { + free(img->c[i].plane); + img->c[i].plane = NULL; + } +} + +/* 把一个(已反量化的)系数按 DCT 缩解累加进平面。k 为自然序下标。 */ +static void mj_accum(mj_comp *c, int bx, int by, int k, int32_t F) +{ + int u = k & 7, v = k >> 3, n = c->n, x, y; + const short *W; + unsigned char *base; + if (!F || u >= n || v >= n) return; + W = mj_basis(n); + base = c->plane + (size_t)(by * n) * c->pw + (size_t)(bx * n); + for (y = 0; y < n; y++) { + int32_t t = (F * W[v * n + y]) >> 12; + unsigned char *row = base + (size_t)y * c->pw; + for (x = 0; x < n; x++) { + int nv = (int)row[x] + (int)((t * W[u * n + x]) >> 12); + row[x] = (unsigned char)(nv < 0 ? 0 : (nv > 255 ? 255 : nv)); + } + } +} + +/* ------------------------------------------------------------------ */ +/* 表解析 */ +/* ------------------------------------------------------------------ */ +static int mj_dqt(mj_img *img, const unsigned char *d, int len) +{ + int p = 0; + while (p < len) { + int pq = d[p] >> 4, tq = d[p] & 15, i; + p++; + if (tq > 3) return 0; + if (pq == 0) { + if (p + 64 > len) return 0; + for (i = 0; i < 64; i++) img->qt[tq][mj_zigzag[i]] = d[p + i]; + p += 64; + } else { + if (p + 128 > len) return 0; + for (i = 0; i < 64; i++) + img->qt[tq][mj_zigzag[i]] = (d[p + 2 * i] << 8) | d[p + 2 * i + 1]; + p += 128; + } + img->have_qt[tq] = 1; + } + return 1; +} + +static int mj_dht(mj_img *img, const unsigned char *d, int len) +{ + int p = 0; + while (p < len) { + int tc, th, i, total = 0; + if (p + 17 > len) return 0; + tc = d[p] >> 4; th = d[p] & 15; p++; + if (th > 3 || tc > 1) return 0; + { + mj_huff *h = tc ? &img->ac[th] : &img->dc[th]; + for (i = 1; i <= 16; i++) { h->bits[i] = d[p + i - 1]; total += d[p + i - 1]; } + p += 16; + if (total > 256 || p + total > len) return 0; + for (i = 0; i < total; i++) h->vals[i] = d[p + i]; + p += total; + mj_huff_build(h); + if (tc) img->have_ac[th] = 1; else img->have_dc[th] = 1; + } + } + return 1; +} + +/* ------------------------------------------------------------------ */ +/* SOF:尺寸/采样因子/分量的平面分配 */ +/* ------------------------------------------------------------------ */ +static int mj_ceil_div(int a, int b) { return (a + b - 1) / b; } + +/* 缩解阶 N:每 8×8 块只解出 N×N 像素(N/8 = 输出/源分辨率)。 + * 取「不欠采样」的最小 N:ceil(8·maxdim/longside)。N=8 表示几乎不缩解(≈原尺寸), + * 这种图上 stb 旧路径的峰值本来就受控,直接交回它,档位对外表现不变。 */ +static int mj_pick_n(int longside, int maxdim) +{ + int n = (8 * maxdim + longside - 1) / longside; + if (n < 1) n = 1; + if (n > 7) return 0; + return n; +} + +/* 按各分量当前的 n 计算块网格与平面尺寸,返回平面总字节。 */ +static unsigned mj_layout(mj_img *img) +{ + unsigned total = 0; + int i; + for (i = 0; i < img->ncomp; i++) { + mj_comp *c = &img->c[i]; + int sw = mj_ceil_div(img->width * c->h, img->hmax); + int sh = mj_ceil_div(img->height * c->v, img->vmax); + c->ow = mj_ceil_div(sw, 8); + c->oh = mj_ceil_div(sh, 8); + c->pw = (img->mcus_x * c->h > c->ow ? img->mcus_x * c->h : c->ow) * c->n; + c->ph = (img->mcus_y * c->v > c->oh ? img->mcus_y * c->v : c->oh) * c->n; + c->vw = mj_ceil_div(sw * c->n, 8); + c->vh = mj_ceil_div(sh * c->n, 8); + if (c->vw > c->pw) c->vw = c->pw; + if (c->vh > c->ph) c->vh = c->ph; + total += (unsigned)c->pw * (unsigned)c->ph; + } + return total; +} + +static int mj_sof(mj_img *img, int marker, const unsigned char *d, int len, int maxdim) +{ + int i, n, longside, base_n; + unsigned int plane_bytes, target_bytes; + int tw, th; + + if (marker != 0xC0 && marker != 0xC1 && marker != 0xC2) return 0; /* 算术/无损/差分 */ + if (len < 6) return 0; + if (d[0] != 8) return 0; /* 12bit 精度不支持 */ + img->height = (d[1] << 8) | d[2]; + img->width = (d[3] << 8) | d[4]; + n = d[5]; + if (img->width <= 0 || img->height <= 0 || n < 1 || n > 3) return 0; /* 4 分量 CMYK → stb */ + if (len < 6 + n * 3) return 0; + img->ncomp = n; + img->progressive = (marker == 0xC2); + + longside = img->width > img->height ? img->width : img->height; + if (longside <= maxdim) return 0; /* 小图走旧入口 */ + base_n = mj_pick_n(longside, maxdim); + if (!base_n) return 0; /* 几乎等尺寸:交回 stb 旧路径 */ + + img->hmax = img->vmax = 1; + for (i = 0; i < n; i++) { + const unsigned char *p = d + 6 + i * 3; + mj_comp *c = &img->c[i]; + c->id = p[0]; + c->h = p[1] >> 4; + c->v = p[1] & 15; + c->tq = p[2]; + if (c->h < 1 || c->v < 1 || c->h > 4 || c->v > 4 || c->tq > 3) return 0; + if (c->h > img->hmax) img->hmax = c->h; + if (c->v > img->vmax) img->vmax = c->v; + } + img->mcus_x = mj_ceil_div(img->width, 8 * img->hmax); + img->mcus_y = mj_ceil_div(img->height, 8 * img->vmax); + + meow_img_target_dims(img->width, img->height, maxdim, &tw, &th); + target_bytes = (unsigned)tw * (unsigned)th * 4u; + + for (i = 0; i < n; i++) img->c[i].n = base_n; + plane_bytes = mj_layout(img); + + /* 4:4:4 的色度平面与亮度等大(正常 4:2:0 只 1/4):亮度平面超过 400KB 时色度降一档, + * 视觉近无损,省下的正是色度要多占的两份。 */ + if (n == 3 && img->c[0].h == img->c[1].h && img->c[0].v == img->c[1].v && + plane_bytes / 3 > 400u * 1024u) { + for (i = 1; i < n; i++) if (img->c[i].n > 1) img->c[i].n >>= 1; + plane_bytes = mj_layout(img); + } + + /* 预算 2.4MB(平面 + 目标图):超了先降色度(亮度保持清晰),再整体降档。 + * 12MP 4:2:0 一档:1.13MB→0.94MB,输出仍是 480×720。 */ + if (plane_bytes + target_bytes > 2400u * 1024u) { + int ch = 0; + for (i = 1; i < n; i++) if (img->c[i].n > 1) { img->c[i].n >>= 1; ch = 1; } + if (ch) plane_bytes = mj_layout(img); + } + /* 最后一道防线:异常巨图整体降档(最多降到 N=1),不至于一次吃掉几十 MB。 */ + while (plane_bytes + target_bytes > MJ_PEAK_CAP && base_n > 1) { + base_n >>= 1; + for (i = 0; i < n; i++) if (img->c[i].n > base_n) img->c[i].n = base_n; + plane_bytes = mj_layout(img); + } + + for (i = 0; i < n; i++) { + mj_comp *c = &img->c[i]; + size_t bytes = (size_t)c->pw * (size_t)c->ph; + c->plane = (unsigned char *)malloc(bytes); + if (!c->plane) return 0; + memset(c->plane, 128, bytes); /* 中性灰底,系数累加即 IDCT */ + c->pred = 0; + } + return 1; +} + +/* ------------------------------------------------------------------ */ +/* 熵解码:块 */ +/* ------------------------------------------------------------------ */ +static int mj_block_seq(mj_img *img, mj_comp *c, int bx, int by) +{ + mj_src *s = &img->src; + int t = mj_hdecode(s, &img->dc[c->td]); + int k; + if (t > 16) return 0; + c->pred += t ? mj_extend(mj_getbits(s, t), t) : 0; + if (c->pred > (1 << 20) || c->pred < -(1 << 20)) c->pred = 0; + mj_accum(c, bx, by, 0, (int32_t)c->pred * img->qt[c->tq][0]); + for (k = 1; k < 64; ) { + int rs = mj_hdecode(s, &img->ac[c->ta]); + int r = rs >> 4, sz = rs & 15; + if (sz == 0) { + if (r < 15) break; /* EOB */ + k += 16; /* ZRL */ + continue; + } + k += r; + if (k > 63) break; + { + int v = mj_extend(mj_getbits(s, sz), sz); + int nk = mj_zigzag[k]; + if (v > 1023) v = 1023; + if (v < -1023) v = -1023; + mj_accum(c, bx, by, nk, (int32_t)v * img->qt[c->tq][nk]); + k++; + } + } + img->blocks++; + return 1; +} + +static int mj_block_dc_prog(mj_img *img, mj_comp *c, int bx, int by, int al) +{ + mj_src *s = &img->src; + int t = mj_hdecode(s, &img->dc[c->td]); + if (t > 16) return 0; + c->pred += t ? (mj_extend(mj_getbits(s, t), t) << al) : 0; + if (c->pred > (1 << 20) || c->pred < -(1 << 20)) c->pred = 0; + mj_accum(c, bx, by, 0, (int32_t)c->pred * img->qt[c->tq][0]); + img->blocks++; + return 1; +} + +/* 跳过一段熵编码数据(不解码),停在下一个非 RST 的 marker 上。 + * 用途:渐进式的精细化扫描(Ah>0)对缩略图无价值,但编码器可能把频带扫描与精细化交错, + * 所以只跳过、不中止——后续的 Ah=0 扫描仍会解码。 */ +static void mj_skip_entropy(mj_img *img) +{ + mj_src *s = &img->src; + s->bitcnt = 0; + s->bitbuf = 0; + for (;;) { + while (!s->over) (void)mj_eb(s); + if (s->over && s->marker >= 0xD0 && s->marker <= 0xD7) { + s->over = 0; /* RSTn:重同步后继续扫 */ + s->marker = 0; + continue; + } + break; + } +} + +/* 重启标记:对齐位缓冲并吃掉 RSTn,重置 DC 预测。 */ +static void mj_restart(mj_img *img) +{ + mj_src *s = &img->src; + int i; + s->bitcnt = 0; + s->bitbuf = 0; + if (s->over) { + if (s->marker >= 0xD0 && s->marker <= 0xD7) { s->over = 0; s->marker = 0; } + } else { + int c; + for (;;) { + c = mj_raw(s); + if (c < 0) { s->over = 1; break; } + if (c != 0xFF) continue; + do { c = mj_raw(s); if (c < 0) { s->over = 1; break; } } while (c == 0xFF); + if (c < 0) break; + if (c >= 0xD0 && c <= 0xD7) break; + s->marker = c; s->over = 1; + break; + } + } + for (i = 0; i < img->ncomp; i++) img->c[i].pred = 0; +} + +typedef struct { + int ns; + int ci[4]; + int td[4], ta[4]; + int ss, se, ah, al; +} mj_scan; + +/* 顺序(baseline / SOF1)扫描 */ +static void mj_scan_seq(mj_img *img, const mj_scan *sc) +{ + int single = (sc->ns == 1); + int mcus_x = single ? img->c[sc->ci[0]].ow : img->mcus_x; + int mcus_y = single ? img->c[sc->ci[0]].oh : img->mcus_y; + int mx, my, total = 0; + for (my = 0; my < mcus_y; my++) { + for (mx = 0; mx < mcus_x; mx++) { + int j; + if (img->rst && total == img->rst) { + if (img->src.over && !(img->src.marker >= 0xD0 && img->src.marker <= 0xD7)) return; + mj_restart(img); + total = 0; + } + if (img->src.over) return; + if (single) { + mj_comp *c = &img->c[sc->ci[0]]; + c->td = sc->td[0]; c->ta = sc->ta[0]; + mj_block_seq(img, c, mx, my); + } else { + for (j = 0; j < sc->ns; j++) { + mj_comp *c = &img->c[sc->ci[j]]; + int by, bx; + c->td = sc->td[j]; c->ta = sc->ta[j]; + for (by = 0; by < c->v; by++) + for (bx = 0; bx < c->h; bx++) + mj_block_seq(img, c, mx * c->h + bx, my * c->v + by); + } + } + total++; + } + } +} + +/* 渐进式 DC 扫描(Ah=0) */ +static void mj_scan_dc_prog(mj_img *img, const mj_scan *sc) +{ + int single = (sc->ns == 1); + int mcus_x = single ? img->c[sc->ci[0]].ow : img->mcus_x; + int mcus_y = single ? img->c[sc->ci[0]].oh : img->mcus_y; + int mx, my, total = 0; + for (my = 0; my < mcus_y; my++) { + for (mx = 0; mx < mcus_x; mx++) { + int j; + if (img->rst && total == img->rst) { + if (img->src.over && !(img->src.marker >= 0xD0 && img->src.marker <= 0xD7)) return; + mj_restart(img); + total = 0; + } + if (img->src.over) return; + if (single) { + mj_comp *c = &img->c[sc->ci[0]]; + c->td = sc->td[0]; + mj_block_dc_prog(img, c, mx, my, sc->al); + } else { + for (j = 0; j < sc->ns; j++) { + mj_comp *c = &img->c[sc->ci[j]]; + int by, bx; + c->td = sc->td[j]; + for (by = 0; by < c->v; by++) + for (bx = 0; bx < c->h; bx++) + mj_block_dc_prog(img, c, mx * c->h + bx, my * c->v + by, sc->al); + } + } + total++; + } + } +} + +/* 渐进式 AC 扫描(Ah=0,单分量) */ +static void mj_scan_ac_prog(mj_img *img, const mj_scan *sc) +{ + mj_comp *c = &img->c[sc->ci[0]]; + mj_src *s = &img->src; + int eobrun = 0, bx, by, total = 0; + c->ta = sc->ta[0]; + for (by = 0; by < c->oh; by++) { + for (bx = 0; bx < c->ow; bx++) { + if (img->rst && total == img->rst) { + if (img->src.over && !(img->src.marker >= 0xD0 && img->src.marker <= 0xD7)) return; + mj_restart(img); + total = 0; + eobrun = 0; + } + if (s->over) return; + if (eobrun > 0) { + eobrun--; + } else { + int k = sc->ss; + while (k <= sc->se) { + int rs = mj_hdecode(s, &img->ac[c->ta]); + int r = rs >> 4, sz = rs & 15; + if (sz == 0) { + if (r < 15) { + eobrun = (1 << r) - 1; + if (r) eobrun += mj_getbits(s, r); + break; + } + k += 16; + continue; + } + k += r; + if (k > sc->se) break; + { + int v = mj_extend(mj_getbits(s, sz), sz); + int nk = mj_zigzag[k]; + int32_t sv; + if (v > 1023) v = 1023; + if (v < -1023) v = -1023; + sv = (int32_t)v * img->qt[c->tq][nk]; + if (sc->al > 6) sv <<= 6; else sv <<= sc->al; /* Al 上限防御,防 int32 溢出 */ + mj_accum(c, bx, by, nk, sv); + } + k++; + } + } + total++; + img->blocks++; + if (s->over) return; + } + } +} + +/* ------------------------------------------------------------------ */ +/* 输出:分量平面 → 目标尺寸 RGBA(最近邻,与 meow_img_downscale 同口径)*/ +/* ------------------------------------------------------------------ */ +#define MJ_FIX_R 5743 /* 1.402 * 4096 */ +#define MJ_FIX_G 1410 /* 0.34414 * 4096 */ +#define MJ_FIX_G2 2925 /* 0.71414 * 4096 */ +#define MJ_FIX_B 7258 /* 1.772 * 4096 */ + +static unsigned char *mj_output(mj_img *img, int tw, int th, int rgb_mode) +{ + unsigned char *out = (unsigned char *)malloc((size_t)tw * th * 4); + mj_comp *cy = &img->c[0]; + mj_comp *cb = img->ncomp >= 3 ? &img->c[1] : NULL; + mj_comp *cr = img->ncomp >= 3 ? &img->c[2] : NULL; + const unsigned char *py = cy->plane; + const unsigned char *pb = cb ? cb->plane : NULL; + const unsigned char *pc = cr ? cr->plane : NULL; + int x, y; + if (!out) return NULL; + for (y = 0; y < th; y++) { + int sy = (int)((long)y * cy->vh / th); + const unsigned char *ry = py + (size_t)sy * cy->pw; + const unsigned char *rb = pb ? pb + (size_t)((long)y * cb->vh / th) * cb->pw : NULL; + const unsigned char *rc = pc ? pc + (size_t)((long)y * cr->vh / th) * cr->pw : NULL; + unsigned char *dst = out + (size_t)y * tw * 4; + for (x = 0; x < tw; x++) { + int yv = ry[(int)((long)x * cy->vw / tw)]; + unsigned char *o = dst + (size_t)x * 4; + if (!rb) { + o[0] = o[1] = o[2] = (unsigned char)yv; + o[3] = 255; + } else if (rgb_mode) { + o[0] = (unsigned char)yv; + o[1] = rb[(int)((long)x * cb->vw / tw)]; + o[2] = rc[(int)((long)x * cr->vw / tw)]; + o[3] = 255; + } else { + int u = (int)rb[(int)((long)x * cb->vw / tw)] - 128; + int v = (int)rc[(int)((long)x * cr->vw / tw)] - 128; + int r = yv + ((MJ_FIX_R * v) >> 12); + int g = yv - ((MJ_FIX_G * u + MJ_FIX_G2 * v) >> 12); + int b = yv + ((MJ_FIX_B * u) >> 12); + o[0] = (unsigned char)(r < 0 ? 0 : (r > 255 ? 255 : r)); + o[1] = (unsigned char)(g < 0 ? 0 : (g > 255 ? 255 : g)); + o[2] = (unsigned char)(b < 0 ? 0 : (b > 255 ? 255 : b)); + o[3] = 255; + } + } + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* 主解析循环 */ +/* ------------------------------------------------------------------ */ +static int mj_decode(mj_img *img, int maxdim, int *tw, int *th, unsigned char **rgba) +{ + mj_src *s = &img->src; + int have_sof = 0, rgb_mode = 0; + int pending = -1; /* 熵段结束时被位读取器吃掉的 marker(回填给主循环) */ + + if (mj_u16(s) != 0xFFD8) return 0; /* SOI */ + for (;;) { + int code, len; + if (pending >= 0) { + code = pending; + pending = -1; + } else { + /* 找下一个 marker(跳过 FF 填充字节) */ + do { code = mj_raw(s); if (code < 0) return 0; } while (code != 0xFF); + do { code = mj_raw(s); if (code < 0) return 0; } while (code == 0xFF); + } + if (code == 0xD9) break; /* EOI */ + if (code == 0xD8 || code == 0x01 || (code >= 0xD0 && code <= 0xD7)) continue; + len = mj_u16(s); + if (len < 2) return 0; + len -= 2; + if (code == 0xDA) { + int ns, i; + mj_scan sc; + unsigned char hdr[64]; + if (len > (int)sizeof hdr) return 0; + if (!mj_read(s, hdr, (size_t)len)) return 0; + if (len < 1) return 0; + ns = hdr[0]; + if (ns < 1 || ns > 3 || len < 1 + ns * 2 + 3) return 0; + memset(&sc, 0, sizeof sc); + sc.ns = ns; + for (i = 0; i < ns; i++) { + int cid = hdr[1 + i * 2], tt = hdr[2 + i * 2], j, found = -1; + for (j = 0; j < img->ncomp; j++) + if (img->c[j].id == cid) { found = j; break; } + if (found < 0) return 0; + sc.ci[i] = found; + sc.td[i] = tt >> 4; + sc.ta[i] = tt & 15; + if (sc.td[i] > 3 || sc.ta[i] > 3) return 0; + } + sc.ss = hdr[1 + ns * 2]; + sc.se = hdr[2 + ns * 2]; + sc.ah = hdr[3 + ns * 2] >> 4; + sc.al = hdr[3 + ns * 2] & 15; + if (!have_sof) return 0; + if (sc.ss < 0 || sc.se > 63 || sc.ss > sc.se || sc.al > 13) return 0; + for (i = 0; i < ns; i++) { + mj_comp *c = &img->c[sc.ci[i]]; + if (!img->have_qt[c->tq]) return 0; + if (sc.ss == 0 && !img->have_dc[sc.td[i]]) return 0; + if (sc.se > 0 && !img->have_ac[sc.ta[i]]) return 0; + } + if (img->progressive && sc.ah > 0) { + /* 精细化扫描:跳过(不解码),继续找后续 Ah=0 扫描。低频带首次扫描到齐 + * 就够出一张清晰缩略图;整幅系数的 successive-approximation 缓存正是 + * 渐进式峰值失控的根因,这里根本不留。 */ + mj_skip_entropy(img); + s->bitcnt = 0; + s->bitbuf = 0; + if (s->over) { + if (s->marker) pending = s->marker; + s->marker = 0; + s->over = 0; + } + continue; + } + if (!img->progressive) { + mj_scan_seq(img, &sc); + } else if (sc.ss == 0) { + mj_scan_dc_prog(img, &sc); + } else { + if (sc.ns != 1) return 0; + mj_scan_ac_prog(img, &sc); + } + s->bitcnt = 0; + s->bitbuf = 0; + /* 一个熵段结束:位读取器可能已把下一个 marker 吃掉,回填给主循环, + * 否则紧跟其后的 DHT/DQT/SOS 会被漏掉。 */ + if (s->over) { + if (s->marker) pending = s->marker; + s->marker = 0; + s->over = 0; + } + continue; + } + if (len > 0 && code != 0xDA) { + /* APPn/COM 只跳过(APP14 取 Adobe transform,很短);其它段读进 2KB 栈缓冲。 + * 不在 marker 循环里 malloc:EXIF 缩略图/ICC 这类段可能有几百 KB,会把解码峰值抬上去。 */ + if (code >= 0xE0 && code <= 0xEF) { + if (code == 0xEE && len >= 12 && len <= 64) { + unsigned char ad[64]; + if (!mj_read(s, ad, (size_t)len)) return 0; + if (memcmp(ad, "Adobe", 5) == 0) img->adobe_transform = ad[11]; + } else if (!mj_skip(s, len)) { + return 0; + } + continue; + } + if (code == 0xFE) { /* COM */ + if (!mj_skip(s, len)) return 0; + continue; + } + { + unsigned char pbuf[2048]; + if (len > (int)sizeof pbuf) return 0; /* DQT/DHT/SOF 不可能这么长 */ + if (!mj_read(s, pbuf, (size_t)len)) return 0; + switch (code) { + case 0xC0: case 0xC1: case 0xC2: + if (have_sof) return 0; + if (!mj_sof(img, code, pbuf, len, maxdim)) return 0; + have_sof = 1; + break; + case 0xC4: + if (!mj_dht(img, pbuf, len)) return 0; + break; + case 0xDB: + if (!mj_dqt(img, pbuf, len)) return 0; + break; + case 0xDD: + if (len >= 2) img->rst = (pbuf[0] << 8) | pbuf[1]; + break; + default: + break; + } + } + continue; + } + if (len > 0) { + if (!mj_skip(s, len)) return 0; + } + } + + if (!have_sof || img->blocks <= 0) return 0; + + /* 色彩空间:3 分量默认 YCbCr;Adobe transform=0 或分量 ID 为 'R','G','B' 时按 RGB。 */ + if (img->ncomp == 3) { + if (img->adobe_transform == 0) rgb_mode = 1; + if (img->c[0].id == 'R' && img->c[1].id == 'G' && img->c[2].id == 'B') rgb_mode = 1; + } + meow_img_target_dims(img->width, img->height, maxdim, tw, th); + *rgba = mj_output(img, *tw, *th, rgb_mode); + return *rgba ? 1 : 0; +} + +/* ================================================================== */ +/* 后端分派 */ +/* ================================================================== */ +static int mj_is_jpeg(const unsigned char *p, size_t n) +{ + return n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF; +} + +int meow_img_backend_mem(const void *buf, size_t len, int maxdim, int *tw, int *th, unsigned char **rgba) +{ + const unsigned char *p = (const unsigned char *)buf; + if (!buf || len < 8 || maxdim <= 0) return 0; + if (mj_is_jpeg(p, len)) { + mj_img img; + int ok; + memset(&img, 0, sizeof img); + img.adobe_transform = -1; + img.src.mem = p; + img.src.memlen = len; + ok = mj_decode(&img, maxdim, tw, th, rgba); + mj_free(&img); + return ok; + } + if (p[0] == 0x89 && p[1] == 'P' && p[2] == 'N' && p[3] == 'G') + return meow_png_decode_stream(NULL, p, len, maxdim, tw, th, rgba); + if (p[0] == 'B' && p[1] == 'M') + return meow_bmp_decode_stream(NULL, p, len, maxdim, tw, th, rgba); + return 0; +} + +int meow_img_backend_file(const char *path, int maxdim, int *tw, int *th, unsigned char **rgba) +{ + FILE *f; + unsigned char sig[8]; + size_t n; + int ok = 0; + if (!path || maxdim <= 0) return 0; + f = fopen(path, "rb"); + if (!f) return 0; + n = fread(sig, 1, sizeof sig, f); + if (n < 4 || fseek(f, 0, SEEK_SET) != 0) { fclose(f); return 0; } + if (mj_is_jpeg(sig, n)) { + mj_img img; + memset(&img, 0, sizeof img); + img.adobe_transform = -1; + img.src.f = f; + ok = mj_decode(&img, maxdim, tw, th, rgba); + mj_free(&img); + } else if (sig[0] == 0x89 && sig[1] == 'P' && sig[2] == 'N' && sig[3] == 'G') { + ok = meow_png_decode_stream(f, NULL, 0, maxdim, tw, th, rgba); + } else if (sig[0] == 'B' && sig[1] == 'M') { + ok = meow_bmp_decode_stream(f, NULL, 0, maxdim, tw, th, rgba); + } + fclose(f); + return ok; +} diff --git a/sdk/meow_jpeg.h b/sdk/meow_jpeg.h new file mode 100644 index 0000000..11364d8 --- /dev/null +++ b/sdk/meow_jpeg.h @@ -0,0 +1,48 @@ +/* + * meow_jpeg.h — M23 真缩解后端内部接口(**不对外导出**,不在 meow_ui.h 伞头里)。 + * + * 后端直接产出「目标显示尺寸」的 RGBA,不做「先整幅解、再重采样」: + * - JPEG(meow_jpeg.c):DCT 域缩解 1/8、1/4、1/2(N=1/2/4 点 IDCT),渐进式只解到 + * 首个精细化扫描(首个 Ah>0)为止——低频带此时已完整,再往后只是为了更细的量化精度。 + * - PNG(meow_png_stream.c):libz inflate 逐行 defilter,按步长丢行丢列直写目标尺寸。 + * - BMP(meow_png_stream.c):按行 seek + 行内跳列(同一套「只碰需要的行」思路)。 + * 三者峰值都只与「输出尺寸 + 目标图像」相关,不随源图分辨率线性增长。 + * + * 不支持的特性一律返回 0,由 meow_ui_img.c 回退 stb(行为与 M23 之前完全一致): + * 4 分量 CMYK/YCCK、算术编码/无损/分层 JPEG、Adam7 交错 PNG、1/2/4bpp 与 RLE BMP 等。 + * + * 许可证:本文件与 meow_jpeg.c / meow_png_stream.c 为 MeowOS 自写代码(MIT,随 SDK 发布)。 + * 不引入第三方解码器源码——理由见 M23 报告「为什么不用 IJG libjpeg」:libjpeg 系列对 + * 渐进式 JPEG 必须整幅驻留系数阵列(≈1 字节/源像素/分量),正是审计里 +23.5MB 的来源, + * 换成它也无法把渐进式峰值压到 2.5MB 以内。 + */ +#ifndef MEOW_JPEG_H +#define MEOW_JPEG_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 解码 path / 内存里的图,直接产出「长边 ≤ maxdim 且按 meow_img_downscale 同一公式等比缩放」 + * 的 tw×th RGBA(4 通道 straight alpha,malloc 分配,调用方 free)。 + * 返回 1 成功(tw/th/rgba 已填);0 = 不支持/失败/不需要(调用方回退 stb 或旧入口)。 */ +int meow_img_backend_file(const char *path, int maxdim, int *tw, int *th, unsigned char **rgba); +int meow_img_backend_mem(const void *buf, size_t len, int maxdim, int *tw, int *th, unsigned char **rgba); + +/* 目标尺寸:与 meow_img_downscale 同一公式(长边 = maxdim,短边按比例截断,下限 1)。 */ +void meow_img_target_dims(int w, int h, int maxdim, int *tw, int *th); + +/* meow_png_stream.c 提供(PNG 逐行 inflate / BMP 按行跳采样)。f 与 mem 二选一: + * f != NULL 走文件流(只读需要的行/块),否则从 mem[0..len) 读。 */ +int meow_png_decode_stream(FILE *f, const unsigned char *mem, size_t len, + int maxdim, int *tw, int *th, unsigned char **rgba); +int meow_bmp_decode_stream(FILE *f, const unsigned char *mem, size_t len, + int maxdim, int *tw, int *th, unsigned char **rgba); + +#ifdef __cplusplus +} +#endif +#endif /* MEOW_JPEG_H */ diff --git a/sdk/meow_png_stream.c b/sdk/meow_png_stream.c new file mode 100644 index 0000000..b505191 --- /dev/null +++ b/sdk/meow_png_stream.c @@ -0,0 +1,492 @@ +/* + * meow_png_stream.c — M23 真缩解后端(二):PNG 逐行 inflate + BMP 按行跳采样。 + * + * 为什么不用 stb 的 PNG 路径:stb 先整幅 inflate + 反滤波成 raw,再展开成 RGBA 整幅, + * 最后才降采样——1600×2400 PNG 实测 +26.2MB。这里改成「按行流式」: + * - IDAT 边到边 inflate,一次只持有一行(filter 字节 + rowbytes)与上一行; + * - 反滤波后**只把需要的列**写进目标行(最近邻,与 meow_img_downscale 同口径); + * - 不需要的行整行丢弃(但仍要反滤波,filter 依赖上一行); + * - 峰值 ≈ 两行缓冲 + zlib 窗口(32KB) + 目标 RGBA,与源图分辨率无关。 + * 支持:gray / gray+A / RGB / RGBA / 调色板,bit depth 1/2/4/8/16(16bit 取高字节降 8bit), + * tRNS 透明。不支持 → 返回 0 回退 stb:Adam7 交错、非 0 压缩/滤波方法、异常色型组合。 + * + * BMP:文件流按目标行直接 seek 到源行(不读全图),行内跳列采样。支持 8bpp 调色板 / + * 16bpp RGB555 / 24bpp / 32bpp(BI_RGB);RLE、BITFIELDS、1/2/4bpp → 回退 stb。 + * + * 许可证:MeowOS 自写(MIT,随 SDK 发布)。PNG/zlib 语义参考 RFC 2083 / zlib 手册; + * BMP 布局参考 stb_image 的解析口径(同为本仓已有依赖,行为对齐)。 + */ +#include "meow_jpeg.h" + +#include +#include +#include +#include + +/* ---------------- zlib ABI ---------------- */ +#if defined(__has_include) +# if __has_include() +# include +# define MZP_HAVE_H 1 +# endif +#endif +#ifndef MZP_HAVE_H +/* 设备 musl sysroot 没有 zlib.h(meow_font.c 同此口径):按 libz.a 的 ABI 声明最小 API。 + * 指针/unsigned long 交给编译器定尺寸,宿主 64 位与设备 32 位都成立。 */ +typedef unsigned char mzp_Bytef; +typedef unsigned int mzp_uInt; +typedef unsigned long mzp_uLong; +typedef void *mzp_voidpf; +typedef struct mzp_stream_s { + mzp_Bytef *next_in; mzp_uInt avail_in; mzp_uLong total_in; + mzp_Bytef *next_out; mzp_uInt avail_out; mzp_uLong total_out; + char *msg; + void *state; + void *zalloc; + void *zfree; + mzp_voidpf opaque; + int data_type; + mzp_uLong adler; + mzp_uLong reserved; +} mzp_stream; +extern int inflateInit_(mzp_stream *, const char *, int); +extern int inflate(mzp_stream *, int); +extern int inflateEnd(mzp_stream *); +extern const char *zlibVersion(void); +# define z_stream mzp_stream +# define Z_OK 0 +# define Z_STREAM_END 1 +# define Z_NO_FLUSH 0 +#endif + +/* ---------------- 字节源 ---------------- */ +typedef struct { + FILE *f; + const unsigned char *mem; + size_t memlen, mempos; +} png_src; + +static int ps_read(png_src *s, void *dst, size_t n) +{ + if (s->mem) { + if (s->mempos + n > s->memlen) return 0; + memcpy(dst, s->mem + s->mempos, n); + s->mempos += n; + return 1; + } + return fread(dst, 1, n, s->f) == n; +} + +static int ps_skip(png_src *s, size_t n) +{ + if (s->mem) { + if (s->mempos + n > s->memlen) return 0; + s->mempos += n; + return 1; + } + return fseek(s->f, (long)n, SEEK_CUR) == 0; +} + +/* ================================================================== */ +/* PNG */ +/* ================================================================== */ +typedef struct { + png_src src; + z_stream z; + int z_ready, z_end; + unsigned w, h; + int depth, color; + int bits; /* 每像素位数 */ + size_t rowbytes; + unsigned char *line; /* 1 filter 字节 + rowbytes */ + unsigned char *prev; /* 上一行的像素字节 */ + unsigned char *cur; /* 本行的像素字节 */ + size_t need, got; + unsigned char pal[256][3]; + unsigned char trns_a[256]; /* 调色板透明度 */ + int pal_n; + int have_trns; + unsigned trns[3]; /* gray / RGB 的 tRNS 值(已降到 8bit) */ + unsigned char *out; + int tw, th; + int next_ty; + unsigned y; +} png_ctx; + +/* 从行里取 px 列的 8bit 通道;返回通道数(写入 o[0..3] 的 RGB(A) 语义由调用方定)。 */ +static void png_get_px(const png_ctx *c, const unsigned char *row, int x, unsigned char *o) +{ + int d = c->depth; + if (d < 8) { + int per = 8 / d; /* 每字节像素数 */ + int byte = x / per; + int shift = 8 - d * (x % per + 1); + int v = (row[byte] >> shift) & ((1 << d) - 1); + if (c->color == 3) { /* 调色板:子字节值即索引 */ + o[0] = c->pal[v][0]; o[1] = c->pal[v][1]; o[2] = c->pal[v][2]; + o[3] = c->trns_a[v]; + } else { + unsigned char g = (unsigned char)(v * 255 / ((1 << d) - 1)); + o[0] = o[1] = o[2] = g; + o[3] = 255; + } + return; + } + if (d == 8) { + if (c->color == 0) { /* gray */ + o[0] = o[1] = o[2] = row[x]; + o[3] = 255; + } else if (c->color == 2) { /* RGB */ + o[0] = row[x * 3]; o[1] = row[x * 3 + 1]; o[2] = row[x * 3 + 2]; + o[3] = 255; + } else if (c->color == 3) { /* palette */ + int i = row[x]; + o[0] = c->pal[i][0]; o[1] = c->pal[i][1]; o[2] = c->pal[i][2]; + o[3] = c->trns_a[i]; + } else if (c->color == 4) { /* gray + A */ + o[0] = o[1] = o[2] = row[x * 2]; + o[3] = row[x * 2 + 1]; + } else { /* RGBA */ + o[0] = row[x * 4]; o[1] = row[x * 4 + 1]; + o[2] = row[x * 4 + 2]; o[3] = row[x * 4 + 3]; + } + return; + } + /* 16bit:取高字节 */ + if (c->color == 0) { + o[0] = o[1] = o[2] = row[x * 2]; + o[3] = (c->have_trns && row[x * 2] == c->trns[0]) ? 0 : 255; + } else if (c->color == 2) { + o[0] = row[x * 6]; o[1] = row[x * 6 + 2]; o[2] = row[x * 6 + 4]; + o[3] = (c->have_trns && row[x * 6] == c->trns[0] && + row[x * 6 + 2] == c->trns[1] && row[x * 6 + 4] == c->trns[2]) ? 0 : 255; + } else if (c->color == 4) { + o[0] = o[1] = o[2] = row[x * 4]; + o[3] = row[x * 4 + 2]; + } else { + o[0] = row[x * 8]; o[1] = row[x * 8 + 2]; + o[2] = row[x * 8 + 4]; o[3] = row[x * 8 + 6]; + } +} + +/* 一行反滤波完成后:按需写目标行。 */ +static int png_emit_row(png_ctx *c) +{ + unsigned char ft = c->line[0]; + const unsigned char *src = c->line + 1; + unsigned char *dst = c->cur; + size_t x, bpp = (size_t)((c->bits + 7) / 8); + if (bpp < 1) bpp = 1; + if (ft > 4) return 0; + for (x = 0; x < c->rowbytes; x++) { + unsigned char left = (x >= bpp) ? dst[x - bpp] : 0; + unsigned char up = c->prev ? c->prev[x] : 0; + unsigned char ul = (c->prev && x >= bpp) ? c->prev[x - bpp] : 0; + unsigned char v = src[x]; + switch (ft) { + case 0: dst[x] = v; break; + case 1: dst[x] = (unsigned char)(v + left); break; + case 2: dst[x] = (unsigned char)(v + up); break; + case 3: dst[x] = (unsigned char)(v + (((unsigned)left + (unsigned)up) >> 1)); break; + default: { + int p = (int)left + (int)up - (int)ul; + int pa = p - (int)left; if (pa < 0) pa = -pa; + int pb = p - (int)up; if (pb < 0) pb = -pb; + int pc = p - (int)ul; if (pc < 0) pc = -pc; + unsigned char pred = (pa <= pb && pa <= pc) ? left : (pb <= pc ? up : ul); + dst[x] = (unsigned char)(v + pred); + break; + } + } + } + if (c->next_ty < c->th) { + long sy = (long)c->next_ty * (long)c->h / c->th; + if (sy == (long)c->y) { + unsigned char *o = c->out + (size_t)c->next_ty * c->tw * 4; + int tx; + for (tx = 0; tx < c->tw; tx++) { + int sx = (int)((long)tx * (long)c->w / c->tw); + unsigned char px[4]; + png_get_px(c, dst, sx, px); + o[tx * 4] = px[0]; o[tx * 4 + 1] = px[1]; + o[tx * 4 + 2] = px[2]; o[tx * 4 + 3] = px[3]; + } + c->next_ty++; + } + } + /* 交换 prev/cur(prev 直接成为反滤波基准) */ + { + unsigned char *t = c->prev; + c->prev = c->cur; + c->cur = t; + } + c->y++; + return 1; +} + +#define PNG_NEED_MORE 2 + +static int png_pump(png_ctx *c) +{ + while (c->got < c->need) { + int ret; + c->z.next_out = c->line + c->got; + c->z.avail_out = (unsigned)(c->need - c->got); + ret = inflate(&c->z, Z_NO_FLUSH); + c->got = c->need - c->z.avail_out; + if (ret == Z_STREAM_END) c->z_end = 1; + else if (ret != Z_OK) return 0; + if (c->got < c->need && (c->z.avail_in == 0 || c->z_end)) + return c->z_end ? 0 : PNG_NEED_MORE; + } + if (!png_emit_row(c)) return 0; + c->got = 0; + return 1; +} + +static int png_bits_of(int color, int depth) +{ + switch (color) { + case 0: return depth; /* gray */ + case 2: return depth * 3; /* rgb */ + case 3: return depth; /* palette */ + case 4: return depth * 2; /* gray + a */ + case 6: return depth * 4; /* rgba */ + default: return 0; + } +} + +static int png_depth_ok(int color, int depth) +{ + if (color == 0) return depth == 1 || depth == 2 || depth == 4 || depth == 8 || depth == 16; + if (color == 3) return depth == 1 || depth == 2 || depth == 4 || depth == 8; + if (color == 2 || color == 4 || color == 6) return depth == 8 || depth == 16; + return 0; +} + +int meow_png_decode_stream(FILE *f, const unsigned char *mem, size_t len, + int maxdim, int *tw, int *th, unsigned char **rgba) +{ + png_ctx c; + unsigned char sig[8], hdr[8]; + unsigned char ibuf[4096]; + int have_ihdr = 0, i, ok = 0; + + if (maxdim <= 0) return 0; + memset(&c, 0, sizeof c); + c.src.f = f; + c.src.mem = mem; + c.src.memlen = len; + if (!ps_read(&c.src, sig, 8)) return 0; + if (memcmp(sig, "\x89PNG\r\n\x1a\n", 8) != 0) return 0; + + while (ps_read(&c.src, hdr, 8)) { + unsigned clen = ((unsigned)hdr[0] << 24) | ((unsigned)hdr[1] << 16) | + ((unsigned)hdr[2] << 8) | hdr[3]; + const unsigned char *type = hdr + 4; + if (clen > 0x7FFFFFFFu) return 0; + + if (memcmp(type, "IHDR", 4) == 0) { + unsigned char d[13]; + if (clen < 13 || have_ihdr) return 0; + if (!ps_read(&c.src, d, 13)) return 0; + c.w = ((unsigned)d[0] << 24) | ((unsigned)d[1] << 16) | ((unsigned)d[2] << 8) | d[3]; + c.h = ((unsigned)d[4] << 24) | ((unsigned)d[5] << 16) | ((unsigned)d[6] << 8) | d[7]; + c.depth = d[8]; + c.color = d[9]; + if (d[10] != 0 || d[11] != 0 || d[12] != 0) return 0; /* 只支持非交错 + 标准压缩/滤波 */ + if (c.w == 0 || c.h == 0 || c.w > 65535u || c.h > 65535u) return 0; + if (!png_depth_ok(c.color, c.depth)) return 0; + c.bits = png_bits_of(c.color, c.depth); + c.rowbytes = ((size_t)c.w * c.bits + 7) / 8; + if (c.rowbytes == 0) return 0; + /* 小图不动:与 meow_img_load_file_dim 的「旧入口」判定一致 */ + if ((long)(c.w > c.h ? c.w : c.h) <= maxdim) return 0; + if (clen > 13 && !ps_skip(&c.src, clen - 13)) return 0; + have_ihdr = 1; + c.need = c.rowbytes + 1; + c.line = (unsigned char *)malloc(c.need); + c.prev = (unsigned char *)malloc(c.rowbytes); + c.cur = (unsigned char *)malloc(c.rowbytes); + if (!c.line || !c.prev || !c.cur) goto done; + memset(c.prev, 0, c.rowbytes); + memset(c.cur, 0, c.rowbytes); + for (i = 0; i < 256; i++) c.trns_a[i] = 255; + meow_img_target_dims((int)c.w, (int)c.h, maxdim, &c.tw, &c.th); + c.out = (unsigned char *)malloc((size_t)c.tw * c.th * 4); + if (!c.out) goto done; + memset(c.out, 0, (size_t)c.tw * c.th * 4); + memset(&c.z, 0, sizeof c.z); + if (inflateInit_(&c.z, zlibVersion(), (int)sizeof c.z) != Z_OK) goto done; + c.z_ready = 1; + } else if (memcmp(type, "PLTE", 4) == 0) { + unsigned char d[256 * 3]; + if (clen > sizeof d || clen % 3) return 0; + if (!ps_read(&c.src, d, clen)) return 0; + c.pal_n = (int)(clen / 3); + memcpy(c.pal, d, clen); + } else if (memcmp(type, "tRNS", 4) == 0) { + unsigned char d[256]; + if (clen > sizeof d) return 0; + if (!ps_read(&c.src, d, clen)) return 0; + c.have_trns = 1; + if (c.color == 3) { + for (i = 0; i < (int)clen && i < 256; i++) c.trns_a[i] = d[i]; + } else if (c.color == 0 && clen >= 2) { + c.trns[0] = d[0]; /* 高字节(已降 8bit) */ + } else if (c.color == 2 && clen >= 6) { + c.trns[0] = d[0]; c.trns[1] = d[2]; c.trns[2] = d[4]; + } + } else if (memcmp(type, "IDAT", 4) == 0) { + unsigned remain = clen; + if (!have_ihdr || !c.out) return 0; + while (remain) { + unsigned n = remain > sizeof ibuf ? (unsigned)sizeof ibuf : remain; + if (!ps_read(&c.src, ibuf, n)) goto done; + remain -= n; + c.z.next_in = ibuf; + c.z.avail_in = n; + while (c.z.avail_in && c.next_ty < c.th) { + int r = png_pump(&c); + if (r == 0) goto done; + if (r == PNG_NEED_MORE) break; + } + } + } else if (memcmp(type, "IEND", 4) == 0) { + break; + } else { + if (clen && !ps_skip(&c.src, clen)) return 0; + } + /* 跳过 CRC */ + { + unsigned char crc[4]; + if (!ps_read(&c.src, crc, 4)) break; + } + if (c.out && c.next_ty >= c.th) break; /* 目标行都拿到了,不必读完 */ + } + + if (!have_ihdr || !c.out || c.next_ty < c.th) goto done; + if (c.color == 3 && c.pal_n <= 0) goto done; + *tw = c.tw; + *th = c.th; + *rgba = c.out; + c.out = NULL; + ok = 1; +done: + if (c.z_ready) inflateEnd(&c.z); + free(c.line); + free(c.prev); + free(c.cur); + free(c.out); + return ok; +} + +/* ================================================================== */ +/* BMP */ +/* ================================================================== */ +static unsigned le16(const unsigned char *p) { return (unsigned)p[0] | ((unsigned)p[1] << 8); } +static unsigned le32(const unsigned char *p) +{ + return (unsigned)p[0] | ((unsigned)p[1] << 8) | ((unsigned)p[2] << 16) | ((unsigned)p[3] << 24); +} + +int meow_bmp_decode_stream(FILE *f, const unsigned char *mem, size_t len, + int maxdim, int *tw, int *th, unsigned char **rgba) +{ + png_src src; + unsigned char fh[14], dh[4]; + unsigned char dib[1024]; + unsigned dibsize, dataoff, bpp, compression, colors; + long w, h, rowsize; + int topdown, i; + unsigned char pal[256][3]; + unsigned char *row = NULL, *out = NULL; + int tw_ = 0, th_ = 0, ok = 0; + + if (maxdim <= 0) return 0; + memset(&src, 0, sizeof src); + src.f = f; + src.mem = mem; + src.memlen = len; + if (!ps_read(&src, fh, 14)) return 0; + if (fh[0] != 'B' || fh[1] != 'M') return 0; + dataoff = le32(fh + 10); + if (!ps_read(&src, dh, 4)) return 0; + dibsize = le32(dh); + if (dibsize < 40 || dibsize > sizeof dib) return 0; /* BITMAPCOREHEADER/OS2 → stb */ + memcpy(dib, dh, 4); + if (!ps_read(&src, dib + 4, dibsize - 4)) return 0; + w = (long)(int)le32(dib + 4); + h = (long)(int)le32(dib + 8); + bpp = le16(dib + 14); + compression = le32(dib + 16); + colors = (dibsize >= 36) ? le32(dib + 32) : 0; + if (compression != 0) return 0; /* RLE/BITFIELDS → stb */ + if (w <= 0 || h == 0) return 0; + if (bpp != 8 && bpp != 16 && bpp != 24 && bpp != 32) return 0; + topdown = h < 0; + if (h < 0) h = -h; + if ((long)(w > h ? w : h) <= maxdim) return 0; /* 小图走旧入口 */ + + if (bpp <= 8) { + unsigned n = colors ? colors : 256; + unsigned char e[4]; + if (n > 256) n = 256; + for (i = 0; (unsigned)i < n; i++) { + if (!ps_read(&src, e, 4)) return 0; + pal[i][0] = e[2]; pal[i][1] = e[1]; pal[i][2] = e[0]; + } + for (; i < 256; i++) pal[i][0] = pal[i][1] = pal[i][2] = 0; + } + + rowsize = (w * bpp + 31) / 32 * 4; + meow_img_target_dims((int)w, (int)h, maxdim, &tw_, &th_); + row = (unsigned char *)malloc((size_t)rowsize); + out = (unsigned char *)malloc((size_t)tw_ * th_ * 4); + if (!row || !out) goto done; + + for (int ty = 0; ty < th_; ty++) { + int sy = (int)((long)ty * h / th_); + long file_row = topdown ? sy : (h - 1 - sy); + long off = (long)dataoff + file_row * rowsize; + unsigned char *o = out + (size_t)ty * tw_ * 4; + if (src.mem) { + if (off < 0 || (size_t)off + (size_t)rowsize > src.memlen) goto done; + memcpy(row, src.mem + off, (size_t)rowsize); + } else { + if (fseek(src.f, off, SEEK_SET) != 0) goto done; + if (fread(row, 1, (size_t)rowsize, src.f) != (size_t)rowsize) goto done; + } + for (int tx = 0; tx < tw_; tx++) { + int sx = (int)((long)tx * w / tw_); + unsigned char *p = o + (size_t)tx * 4; + if (bpp == 24) { + const unsigned char *q = row + sx * 3; + p[0] = q[2]; p[1] = q[1]; p[2] = q[0]; p[3] = 255; + } else if (bpp == 32) { + const unsigned char *q = row + sx * 4; + p[0] = q[2]; p[1] = q[1]; p[2] = q[0]; p[3] = 255; /* BI_RGB:alpha 不作为语义 */ + } else if (bpp == 16) { + unsigned v = le16(row + sx * 2); + unsigned r = (v >> 10) & 31, g = (v >> 5) & 31, b = v & 31; + p[0] = (unsigned char)((r << 3) | (r >> 2)); + p[1] = (unsigned char)((g << 3) | (g >> 2)); + p[2] = (unsigned char)((b << 3) | (b >> 2)); + p[3] = 255; + } else { + unsigned idx = row[sx]; + p[0] = pal[idx & 255][0]; p[1] = pal[idx & 255][1]; + p[2] = pal[idx & 255][2]; p[3] = 255; + } + } + } + *tw = tw_; + *th = th_; + *rgba = out; + out = NULL; + ok = 1; +done: + free(row); + free(out); + return ok; +} diff --git a/sdk/meow_ui_img.c b/sdk/meow_ui_img.c index 31c33c2..8f20682 100644 --- a/sdk/meow_ui_img.c +++ b/sdk/meow_ui_img.c @@ -1,10 +1,11 @@ /* - * meow_ui_img.c — 图片层(B1.2)。基于 stb_image(单头文件、静态编入), - * 解 PNG/JPEG/BMP → RGBA,统一最近邻缩放/居中裁方。 + * meow_ui_img.c — 图片层(B1.2)。解码:M23 真缩解后端(meow_jpeg.c / meow_png_stream.c) + * 优先,不支持/失败回退 stb_image(单头文件、静态编入);统一输出 RGBA,最近邻缩放/居中裁方。 */ #include "meow_ui.h" #include "meow_ui_internal.h" #include "stb_image.h" +#include "meow_jpeg.h" /* M23 真缩解后端(内部头,不对外导出) */ #include #include @@ -79,6 +80,16 @@ meow_img *meow_img_load_file_dim(const char *path, int maxdim) { if (!path) return NULL; if (maxdim <= 0) return meow_img_load_file(path); /* 不限尺寸 = 旧入口 */ + /* 追加(M23):真缩解后端直接产出目标尺寸(JPEG 按 DCT 档位;PNG 逐行 inflate 丢行丢列; + * BMP 按行 seek 跳采样)。峰值只与输出尺寸相关。 + * 不支持/失败/几乎等尺寸(4 分量 CMYK、算术编码、交错 PNG、RLE BMP、小图…)返回 0 → + * 落回下面的 stb 旧路径,对外语义与档位表现不变。 */ + { + unsigned char *bp = NULL; + int bw = 0, bh = 0; + if (meow_img_backend_file(path, maxdim, &bw, &bh, &bp) && bp) + return img_finish_dim(bp, bw, bh, maxdim); + } int w = 0, h = 0, c = 0; if (!stbi_info(path, &w, &h, &c)) return NULL; int longside = w > h ? w : h; @@ -121,6 +132,13 @@ meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim) { if (!buf || len <= 0) return NULL; if (maxdim <= 0) return meow_img_load_mem(buf, len); + /* 追加(M23):同文件入口,后端直接从调用方缓冲解码(不再整幅解 + 重采样)。 */ + { + unsigned char *bp = NULL; + int bw = 0, bh = 0; + if (meow_img_backend_mem(buf, (size_t)len, maxdim, &bw, &bh, &bp) && bp) + return img_finish_dim(bp, bw, bh, maxdim); + } int w = 0, h = 0, c = 0; if (!stbi_info_from_memory((const unsigned char *)buf, len, &w, &h, &c)) return NULL; int longside = w > h ? w : h; diff --git a/sdk/test_meow_img_budget.c b/sdk/test_meow_img_budget.c index 275760a..79ab86b 100644 --- a/sdk/test_meow_img_budget.c +++ b/sdk/test_meow_img_budget.c @@ -10,13 +10,15 @@ * B6 进升级:progressive JPEG 走同一缩解路径(峰值记录 + 长边约束) * B7 内存入口:meow_img_load_mem_dim 与文件入口等价(峰值 < 旧内存入口 1/2) * B8 坏图:坏 JPEG(魔数+垃圾)/ 空内存 / 不存在路径 → NULL(不崩) - * B9 PNG/BMP:尺寸约束成立,峰值如实记录(stb 无解码期缩放 → 与旧入口同级) + * B9 PNG/BMP:尺寸约束成立;M23 起 PNG/BMP 也走解码期缩解(逐行 inflate / 按行 seek), + * 峰值应显著低于旧入口(本用例只断言 ≤ maxdim + 非空,并打印峰值供报告引用) * * 夹具(生成一次): * python3 test_meow_img_budget_gen.py "$PREFIX/tmp/meow_img_budget" - * 编译(本 sdk 目录下;--wrap 用于给 malloc/free 家族打点): + * 编译(本 sdk 目录下;--wrap 用于给 malloc/free 家族打点;-lz 供 PNG 后端 inflate): * gcc -Wall -Wextra -I. -Iinclude -DMEOW_UI_HOST_TEST -o test_meow_img_budget \ - * test_meow_img_budget.c meow_ui_img.c stb_image.c -lm \ + * test_meow_img_budget.c meow_ui_img.c meow_jpeg.c meow_png_stream.c stb_image.c \ + * -lm -lz \ * -Wl,--wrap=malloc -Wl,--wrap=calloc -Wl,--wrap=realloc -Wl,--wrap=free * 运行:./test_meow_img_budget "$PREFIX/tmp/meow_img_budget" (exit 0=PASS / 1=FAIL) * 夹具目录可省略:默认 $PREFIX/tmp/meow_img_budget;缺失时自动调 gen 脚本生成。 @@ -421,8 +423,8 @@ int main(int argc, char **argv) CHECK(meow_img_load_mem_dim("x", 3, 480) == NULL, "垃圾内存 → NULL"); } - /* ---- B9 PNG / BMP(stb 无解码期缩放,如实记录峰值) ---- */ - printf("[B9] PNG / BMP:尺寸约束成立,峰值如实记录(stb 无解码期缩放)\n"); + /* ---- B9 PNG / BMP(M23 起也走解码期缩解;只断言尺寸/非空,峰值打印) ---- */ + printf("[B9] PNG / BMP:尺寸约束成立,峰值记录(M23 逐行/跳行缩解,应远低于旧入口)\n"); { struct { const char *tag; const char *path; } fx[] = { { "PNG", p_png }, { "BMP", p_bmp }, @@ -446,8 +448,8 @@ int main(int argc, char **argv) CHECK(nw && meow_img_w(nw) <= 480 && meow_img_h(nw) <= 480 && nonblank(nw), name); peak_report(fx[i].tag, p_old); peak_report(" ↑ 新入口", p_new); - snprintf(name, sizeof name, "%s 峰值与旧入口同级(stb 无解码期缩放)", fx[i].tag); - CHECK(p_new <= p_old + p_old / 8, name); + snprintf(name, sizeof name, "%s 新入口峰值 ≤ 旧入口(不再整幅物化)", fx[i].tag); + CHECK(p_new <= p_old, name); if (old) printf(" %s 原图 %dx%d\n", fx[i].tag, meow_img_w(old), meow_img_h(old)); meow_img_free(nw); } diff --git a/sdk/test_meow_img_stream.c b/sdk/test_meow_img_stream.c new file mode 100644 index 0000000..509b3ac --- /dev/null +++ b/sdk/test_meow_img_stream.c @@ -0,0 +1,762 @@ +/* test_meow_img_stream.c — M23 真缩解后端宿主验证(峰值 + 像素一致性 + 回退)。 + * + * 覆盖矩阵(对齐 M23 任务 3/4/5): + * S1 探针/入口透明:meow_img_probe_file、load_file_dim / load_mem_dim 对新后端透明; + * 小图逐字节等价旧入口;maxdim<=0 等价旧入口 + * S2 baseline 2000×3000:maxdim=720/480 峰值 ≤ 2.5MiB(旧入口如实并列打印) + * S3 progressive 2000×3000:同一断言(渐进式不再整幅驻留系数) + * S4 PNG 1600×2400:峰值 ≤ 2.5MiB(逐行 inflate,无整幅 raw/RGBA) + * S5 BMP 1600×2400:峰值 ≤ 2.5MiB(按行 seek,不读全图) + * S6 4MP/8MP/12MP JPEG:都能解且峰值 ≤ 2.5MiB;峰值增长远小于像素数增长(非线性) + * S7 像素一致性:与「stb 整幅解码 + meow_img_downscale」网格比对(容差内) + * S8 内存入口 == 文件入口(逐字节),峰值同级 + * S9 回退:CMYK JPEG(4 分量)、1bpp BMP、Adam7 交错 PNG → 后端拒绝,stb 仍成功; + * 截断/垃圾 → NULL(不崩) + * + * 夹具:本文件内置生成脚本(Pillow),首次运行时写到夹具目录并执行。 + * 编译(本 sdk 目录下;--wrap 用于给 malloc/free 家族打点): + * gcc -Wall -Wextra -O2 -I. -Iinclude -DMEOW_UI_HOST_TEST -o test_meow_img_stream \ + * test_meow_img_stream.c meow_ui_img.c meow_jpeg.c meow_png_stream.c stb_image.c \ + * -lm -lz \ + * -Wl,--wrap=malloc -Wl,--wrap=calloc -Wl,--wrap=realloc -Wl,--wrap=free + * 运行:./test_meow_img_stream "$PREFIX/tmp/meow_img_stream" (exit 0=PASS / 1=FAIL) + */ +#define _GNU_SOURCE +#include "meow_ui.h" +#include "meow_ui_internal.h" /* meow_gfx_blit_rgba 原型(本测试只给桩) */ +#include "meow_jpeg.h" /* 后端入口(内部头):直接比对「后端 vs 回退」 */ +#include "stb_image.h" /* 旧 _dim 路径基线(stbi_load_scaled,M23 之前的行为) */ + +#include +#include +#include +#include +#include +#include /* clock():解码耗时打印 */ +#include /* sqrt:结构相关系数 */ + +/* ------------------------------------------------------------------ */ +/* 分配打点 */ +/* ------------------------------------------------------------------ */ +#define AHDR 32 + +static size_t g_live = 0; +static size_t g_peak = 0; + +extern void *__real_malloc(size_t); +extern void *__real_calloc(size_t, size_t); +extern void *__real_realloc(void *, size_t); +extern void __real_free(void *); + +static void acc_alloc(size_t n) +{ + g_live += n; + if (g_live > g_peak) g_peak = g_live; +} + +void *__wrap_malloc(size_t n) +{ + size_t *h = (size_t *)__real_malloc(n + AHDR); + if (!h) return NULL; + h[0] = n; + acc_alloc(n); + return (char *)h + AHDR; +} + +void *__wrap_calloc(size_t a, size_t b) +{ + size_t n = a * b; + void *p = __wrap_malloc(n); + if (p) memset(p, 0, n); + return p; +} + +void __wrap_free(void *p) +{ + size_t *h; + if (!p) return; + h = (size_t *)((char *)p - AHDR); + g_live -= h[0]; + __real_free(h); +} + +void *__wrap_realloc(void *p, size_t n) +{ + size_t old = p ? ((size_t *)((char *)p - AHDR))[0] : 0; + void *q = __wrap_malloc(n); + if (!q) return NULL; + if (p) { + memcpy(q, p, old < n ? old : n); + __wrap_free(p); + } + return q; +} + +static size_t peak_reset(void) { g_peak = g_live; return g_live; } +static size_t peak_since(size_t base) { return g_peak - base; } + +/* meow_ui_img.c 的绘制函数需要的 gfx 符号:本测试只测解码/内存,桩即可。 */ +void meow_gfx_blit_rgba(int x, int y, int w, int h, const uint8_t *rgba, int alpha) +{ + (void)x; (void)y; (void)w; (void)h; (void)rgba; (void)alpha; +} + +/* ------------------------------------------------------------------ */ +/* 断言与打印 */ +/* ------------------------------------------------------------------ */ +static int g_fail = 0; +#define CHECK(cond, name) do { \ + if (cond) printf(" PASS %s\n", name); \ + else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fail++; } } while (0) + +#define PEAK_2500KB ((size_t)2560 * 1024) /* 2.5 MiB */ + +static void peak_report(const char *tag, size_t bytes) +{ + printf(" peak %-32s %7.2f MiB (%zu B)\n", tag, (double)bytes / (1024.0 * 1024.0), bytes); +} + +/* ------------------------------------------------------------------ */ +/* 夹具 */ +/* ------------------------------------------------------------------ */ +static const char *g_dir = NULL; + +static const char *g_gen_py = +"import os, sys, zlib, struct\n" +"from PIL import Image, ImageDraw\n" +"out = sys.argv[1]\n" +"os.makedirs(out, exist_ok=True)\n" +"def pat(w, h, mode='RGB'):\n" +" im = Image.new(mode, (w, h), (250, 249, 245) if mode == 'RGB' else 200)\n" +" d = ImageDraw.Draw(im)\n" +" for k in range(0, min(w, h) - 200, 40):\n" +" d.rectangle([40 + k, 40 + k, 200 + k, 180 + k], outline=(12, 12, 14), width=3)\n" +" for band in range(14):\n" +" y0 = band * h // 14\n" +" for x in range(0, w, 7):\n" +" d.line([(x, y0), (x + 3, y0 + 5)], fill=(110, 110, 116))\n" +" for i, c in enumerate([(220, 30, 40), (30, 180, 60), (40, 60, 220), (240, 200, 20)]):\n" +" d.rectangle([w // 2 + i * 30, h // 2, w // 2 + i * 30 + 25, h // 2 + 60], fill=c)\n" +" return im\n" +"big = pat(2000, 3000)\n" +"big.save(os.path.join(out, 'base2000.jpg'), 'JPEG', quality=60, subsampling=2)\n" +"big.save(os.path.join(out, 'prog2000.jpg'), 'JPEG', quality=60, subsampling=2, progressive=True)\n" +"big.save(os.path.join(out, 'base2000_444.jpg'), 'JPEG', quality=60, subsampling=0)\n" +"big.save(os.path.join(out, 'base2000_422.jpg'), 'JPEG', quality=60, subsampling=1)\n" +"big.save(os.path.join(out, 'restart2000.jpg'), 'JPEG', quality=60, subsampling=2,\n" +" restart_marker_blocks=8)\n" +"pat(640, 480).save(os.path.join(out, 'sweep640.jpg'), 'JPEG', quality=70, subsampling=2)\n" +"big.convert('L').save(os.path.join(out, 'gray2000.jpg'), 'JPEG', quality=60)\n" +"photo = Image.blend(big, Image.effect_noise((2000, 3000), 40).convert('RGB'), 0.35)\n" +"photo.save(os.path.join(out, 'photo2000.jpg'), 'JPEG', quality=75, subsampling=2)\n" +"photo.save(os.path.join(out, 'photo2000p.jpg'), 'JPEG', quality=75, subsampling=2, progressive=True)\n" +"pat(1600, 2400).save(os.path.join(out, 'page1600.png'), 'PNG')\n" +"pat(1600, 2400).save(os.path.join(out, 'page1600.bmp'), 'BMP')\n" +"pat(2320, 1740).save(os.path.join(out, 'mp4.jpg'), 'JPEG', quality=60, subsampling=2)\n" +"pat(3264, 2448).save(os.path.join(out, 'mp8.jpg'), 'JPEG', quality=60, subsampling=2)\n" +"pat(4000, 3000).save(os.path.join(out, 'mp12.jpg'), 'JPEG', quality=60, subsampling=2)\n" +"pat(64, 64).save(os.path.join(out, 'small64.jpg'), 'JPEG', quality=80)\n" +"Image.open(os.path.join(out, 'page1600.png')).convert('L').save(os.path.join(out, 'gray.png'))\n" +"pat(800, 600).convert('P', palette=Image.ADAPTIVE, colors=64).save(os.path.join(out, 'pal.png'))\n" +"pat(800, 600).convert('RGBA').save(os.path.join(out, 'rgba.png'))\n" +"big.convert('CMYK').save(os.path.join(out, 'cmyk.jpg'), 'JPEG', quality=60)\n" +"Image.open(os.path.join(out, 'page1600.png')).convert('1').save(os.path.join(out, 'mono1.bmp'))\n" +"def chunk(t, d):\n" +" c = struct.pack('>I', len(d)) + t + d\n" +" return c + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff)\n" +"def adam7(path, w, h):\n" +" raw = bytearray()\n" +" passes = [(0,0,8,8),(4,0,8,8),(0,4,4,8),(2,0,4,4),(0,2,2,4),(1,0,2,2),(0,1,1,2)]\n" +" for (x0, y0, dx, dy) in passes:\n" +" xs = list(range(x0, w, dx)); ys = list(range(y0, h, dy))\n" +" if not xs or not ys:\n" +" continue\n" +" for y in ys:\n" +" raw.append(0)\n" +" for x in xs:\n" +" raw += bytes(((x * 3) & 255, (y * 3) & 255, ((x + y) * 2) & 255))\n" +" ihdr = struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 1)\n" +" data = (b'\\x89PNG\\r\\n\\x1a\\n' + chunk(b'IHDR', ihdr) +\n" +" chunk(b'IDAT', zlib.compress(bytes(raw))) + chunk(b'IEND', b''))\n" +" open(path, 'wb').write(data)\n" +"adam7(os.path.join(out, 'adam7.png'), 1600, 2400)\n" +"open(os.path.join(out, 'broken.jpg'), 'wb').write(b'\\xff\\xd8\\xff\\xe0' +\n" +" bytes((i * 37 + 11) & 0xFF for i in range(256)))\n" +"for n in sorted(os.listdir(out)):\n" +" print(' %-14s %9d B' % (n, os.path.getsize(os.path.join(out, n))))\n"; + +static char *path_join(const char *name) +{ + size_t n = strlen(g_dir) + strlen(name) + 2; + char *p = (char *)malloc(n); + if (!p) { printf("OOM\n"); exit(1); } + snprintf(p, n, "%s/%s", g_dir, name); + return p; +} + +static int file_exists(const char *p) +{ + struct stat st; + return stat(p, &st) == 0 && st.st_size > 0; +} + +static void ensure_fixtures(void) +{ + char *mark = path_join("base2000.jpg"); + int have = file_exists(mark); + free(mark); + if (have) return; + mkdir(g_dir, 0755); + printf("夹具缺失,生成到 %s(Pillow)\n", g_dir); + { + char *py = path_join("gen_stream_fixtures.py"); + char cmd[1200]; + FILE *f = fopen(py, "w"); + int rc; + if (!f) { printf("FATAL 无法写 %s\n", py); exit(1); } + fputs(g_gen_py, f); + fclose(f); + snprintf(cmd, sizeof cmd, "python3 '%s' '%s'", py, g_dir); + rc = system(cmd); + free(py); + if (rc != 0) { + printf("FATAL 夹具生成失败(缺 Pillow?)\n"); + exit(1); + } + } +} + +static uint8_t *read_whole(const char *p, size_t *len) +{ + FILE *f = fopen(p, "rb"); + uint8_t *buf; + long n; + if (!f) return NULL; + fseek(f, 0, SEEK_END); + n = ftell(f); + fseek(f, 0, SEEK_SET); + if (n <= 0) { fclose(f); return NULL; } + buf = (uint8_t *)malloc((size_t)n); + if (!buf) { fclose(f); return NULL; } + if (fread(buf, 1, (size_t)n, f) != (size_t)n) { free(buf); fclose(f); return NULL; } + *len = (size_t)n; + fclose(f); + return buf; +} + +/* ------------------------------------------------------------------ */ +/* 图像辅助 */ +/* ------------------------------------------------------------------ */ +static int nonblank(const meow_img *im) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *p = meow_img_pixels(im); + long sum = 0; + if (!p || w <= 0 || h <= 0) return 0; + for (long i = 0; i < (long)w * h; i += 97) + sum += p[i * 4] + p[i * 4 + 1] + p[i * 4 + 2]; + return sum > 0; +} + +/* 求矩形区域的亮度均值(用于块均值相关:消除采样相位差与高频噪声,只看结构)。 */ +static double block_mean(const meow_img *im, int x0, int y0, int x1, int y1) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *p = meow_img_pixels(im); + double s = 0; + long n = 0; + if (!p) return 0; + if (x1 > w) x1 = w; + if (y1 > h) y1 = h; + for (int y = y0; y < y1; y++) + for (int x = x0; x < x1; x++) { + const unsigned char *q = p + ((size_t)y * w + x) * 4; + s += (q[0] + q[1] + q[2]) / 3.0; + n++; + } + return n ? s / n : 0; +} + +/* 与「整幅 stb 解码 + meow_img_downscale」比对:按比例网格采样,返回各通道平均绝对差; + * corr 非空时同时给出「网格块均值」的亮度相关系数(结构一致性)。 */ +static double mean_abs_diff(const meow_img *a, const meow_img *b, int grid, double *corr) +{ + int wa = meow_img_w(a), ha = meow_img_h(a); + int wb = meow_img_w(b), hb = meow_img_h(b); + const unsigned char *pa = meow_img_pixels(a), *pb = meow_img_pixels(b); + long acc = 0, cnt = 0; + if (!pa || !pb || wa <= 0 || hb <= 0) return 1e9; + for (int gy = 0; gy < grid; gy++) { + int ya = (int)((long)gy * ha / grid); + int yb = (int)((long)gy * hb / grid); + for (int gx = 0; gx < grid; gx++) { + int xa = (int)((long)gx * wa / grid); + int xb = (int)((long)gx * wb / grid); + const unsigned char *qa = pa + ((size_t)ya * wa + xa) * 4; + const unsigned char *qb = pb + ((size_t)yb * wb + xb) * 4; + for (int k = 0; k < 3; k++) { + int d = (int)qa[k] - (int)qb[k]; + acc += d < 0 ? -d : d; + cnt++; + } + } + } + if (corr && cnt) { + double s1 = 0, s2 = 0, s11 = 0, s22 = 0, s12 = 0; + long n = 0; + for (int gy = 0; gy < grid; gy++) { + int ya0 = (int)((long)gy * ha / grid), ya1 = (int)((long)(gy + 1) * ha / grid); + int yb0 = (int)((long)gy * hb / grid), yb1 = (int)((long)(gy + 1) * hb / grid); + if (ya1 <= ya0) ya1 = ya0 + 1; + if (yb1 <= yb0) yb1 = yb0 + 1; + for (int gx = 0; gx < grid; gx++) { + int xa0 = (int)((long)gx * wa / grid), xa1 = (int)((long)(gx + 1) * wa / grid); + int xb0 = (int)((long)gx * wb / grid), xb1 = (int)((long)(gx + 1) * wb / grid); + double la, lb; + if (xa1 <= xa0) xa1 = xa0 + 1; + if (xb1 <= xb0) xb1 = xb0 + 1; + la = block_mean(a, xa0, ya0, xa1, ya1); + lb = block_mean(b, xb0, yb0, xb1, yb1); + s1 += la; s2 += lb; s11 += la * la; s22 += lb * lb; s12 += la * lb; + n++; + } + } + if (n > 1) { + double va = s11 - s1 * s1 / n, vb = s22 - s2 * s2 / n; + double cv = s12 - s1 * s2 / n; + *corr = (va > 1e-9 && vb > 1e-9) ? cv / sqrt(va * vb) : 1.0; + } else { + *corr = 1.0; + } + } + return cnt ? (double)acc / (double)cnt : 1e9; +} + +/* 旧入口整幅解码 → 参考缩略图(downscale 到 maxdim),用于像素比对。 */ +static meow_img *ref_thumb(const char *path, int maxdim) +{ + meow_img *full = meow_img_load_file(path); + meow_img *small; + if (!full) return NULL; + if (meow_img_w(full) <= maxdim && meow_img_h(full) <= maxdim) return full; + small = meow_img_downscale(full, maxdim); + meow_img_free(full); + return small; +} + +/* 一次「新入口」解码的峰值。 */ +static size_t peak_of_dim(const char *path, int maxdim, meow_img **out) +{ + size_t base = peak_reset(); + meow_img *im = meow_img_load_file_dim(path, maxdim); + size_t peak = peak_since(base); + if (out) *out = im; else meow_img_free(im); + return peak; +} + +/* 旧 _dim 路径基线(M23 之前的实现):stbi_load_scaled + 最近邻精修到 maxdim 的瞬时峰值。 + * 只用于报告里的「前后对比表」——这就是审计里 +10.1MB / +23.5MB / +26.2MB 的那条路径。 */ +static size_t old_dim_peak(const char *path, int maxdim) +{ + int w = 0, h = 0, c = 0, s = 1, longside; + size_t base, peak; + unsigned char *p, *small = NULL; + if (!stbi_info(path, &w, &h, &c)) return 0; + longside = w > h ? w : h; + base = peak_reset(); + while (s < 8 && (longside / (s * 2)) >= maxdim) s *= 2; + p = stbi_load_scaled(path, &w, &h, &c, 4, s); + if (!p) { peak = peak_since(base); return peak; } + if (w > maxdim || h > maxdim) { + int dw, dh; + if (w >= h) { dw = maxdim; dh = (int)((long)h * maxdim / w); } + else { dh = maxdim; dw = (int)((long)w * maxdim / h); } + if (dw < 1) dw = 1; + if (dh < 1) dh = 1; + small = (unsigned char *)malloc((size_t)dw * dh * 4); + if (small) { + for (int dy = 0; dy < dh; dy++) { + int sy = (int)((long)dy * h / dh); + for (int dx = 0; dx < dw; dx++) { + int sx = (int)((long)dx * w / dw); + memcpy(small + ((size_t)dy * dw + dx) * 4, p + ((size_t)sy * w + sx) * 4, 4); + } + } + } + } + peak = peak_since(base); + free(p); + free(small); + return peak; +} + +/* ================================================================== */ +int main(int argc, char **argv) +{ + char *p_base, *p_prog, *p_png, *p_bmp, *p_mp4, *p_mp8, *p_mp12; + char *p_small, *p_gray, *p_pal, *p_rgba, *p_cmyk, *p_mono, *p_adam7, *p_broken; + char *p_444, *p_422, *p_grayjpg, *p_photo, *p_photop; + + if (argc > 1) { + g_dir = argv[1]; + } else { + static char dflt[600]; + const char *tmp = getenv("TMPDIR"); + const char *pre = getenv("PREFIX"); + if (tmp && *tmp) snprintf(dflt, sizeof dflt, "%s/meow_img_stream", tmp); + else if (pre && *pre) snprintf(dflt, sizeof dflt, "%s/tmp/meow_img_stream", pre); + else snprintf(dflt, sizeof dflt, "/tmp/meow_img_stream"); + g_dir = dflt; + } + ensure_fixtures(); + + p_base = path_join("base2000.jpg"); + p_prog = path_join("prog2000.jpg"); + p_png = path_join("page1600.png"); + p_bmp = path_join("page1600.bmp"); + p_mp4 = path_join("mp4.jpg"); + p_mp8 = path_join("mp8.jpg"); + p_mp12 = path_join("mp12.jpg"); + p_small = path_join("small64.jpg"); + p_gray = path_join("gray.png"); + p_pal = path_join("pal.png"); + p_rgba = path_join("rgba.png"); + p_cmyk = path_join("cmyk.jpg"); + p_mono = path_join("mono1.bmp"); + p_adam7 = path_join("adam7.png"); + p_broken = path_join("broken.jpg"); + p_444 = path_join("base2000_444.jpg"); + p_422 = path_join("base2000_422.jpg"); + p_grayjpg = path_join("gray2000.jpg"); + p_photo = path_join("photo2000.jpg"); + p_photop = path_join("photo2000p.jpg"); + + if (!file_exists(p_base)) { printf("FATAL 夹具 %s 缺失\n", p_base); return 1; } + + /* ---- S1 入口透明 ---- */ + printf("[S1] 探针 / 小图 / maxdim<=0\n"); + { + int w = 0, h = 0, c = 0; + meow_img *a = meow_img_load_file(p_small); + meow_img *b = meow_img_load_file_dim(p_small, 64); + meow_img *z = meow_img_load_file_dim(p_small, 0); + CHECK(meow_img_probe_file(p_base, &w, &h, &c) == 1 && w == 2000 && h == 3000, + "probe base2000 → 2000x3000"); + CHECK(a && b && z, "小图三条路径都成功"); + CHECK(a && b && memcmp(meow_img_pixels(a), meow_img_pixels(b), + (size_t)meow_img_w(a) * meow_img_h(a) * 4) == 0, "小图逐字节与旧入口一致"); + CHECK(a && z && memcmp(meow_img_pixels(a), meow_img_pixels(z), + (size_t)meow_img_w(a) * meow_img_h(a) * 4) == 0, "maxdim=0 等价旧入口"); + CHECK(meow_img_probe_file(p_broken, NULL, NULL, NULL) == 0, "截断图 probe → 0"); + meow_img_free(a); meow_img_free(b); meow_img_free(z); + } + + /* ---- S2 baseline 2000×3000 ---- */ + printf("[S2] baseline 2000x3000(maxdim=720/480)\n"); + { + meow_img *im = NULL; + size_t pk720 = peak_of_dim(p_base, 720, &im); + size_t pk480, pk_old; + peak_report("新入口 _dim(720)", pk720); + CHECK(im && meow_img_w(im) == 480 && meow_img_h(im) == 720 && nonblank(im), + "结果 480x720 且非空"); + CHECK(pk720 <= PEAK_2500KB, "峰值 ≤ 2.5 MiB"); + meow_img_free(im); + pk480 = peak_of_dim(p_base, 480, NULL); + peak_report("新入口 _dim(480)", pk480); + CHECK(pk480 <= PEAK_2500KB, "_dim(480) 峰值 ≤ 2.5 MiB"); + { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(p_base); + pk_old = peak_since(base); + peak_report("旧入口 meow_img_load_file", pk_old); + CHECK(old && pk_old > 6 * pk720, "新入口峰值 < 旧入口 1/6(整幅不再物化)"); + meow_img_free(old); + } + } + + /* ---- S2b 其它色度采样/灰度/照片:峰值与压缩体积无关 ---- */ + printf("[S2b] 4:4:4 / 4:2:2 / 灰度 / 照片级噪声(同一 2000x3000, maxdim=720)\n"); + { + char *pe = path_join("base2000_444.jpg"); + char *p4 = path_join("base2000_422.jpg"); + char *pg = path_join("gray2000.jpg"); + char *pp = path_join("photo2000.jpg"); + char *pq = path_join("photo2000p.jpg"); + struct { const char *tag; const char *path; } fx[] = { + { "4:4:4", pe }, { "4:2:2", p4 }, { "gray", pg }, + { "photo baseline", pp }, { "photo progressive", pq }, + }; + for (size_t i = 0; i < sizeof fx / sizeof fx[0]; i++) { + meow_img *im = NULL; + size_t base = peak_reset(); + size_t pk; + long t0 = (long)clock(); + im = meow_img_load_file_dim(fx[i].path, 720); + pk = peak_since(base); + { + char name[128]; + struct stat st; + stat(fx[i].path, &st); + peak_report(fx[i].tag, pk); + printf(" 源文件 %ld KB,解码 %ld ms,输出 %dx%d\n", + (long)st.st_size / 1024, + (long)((clock() - t0) * 1000 / CLOCKS_PER_SEC), + meow_img_w(im), meow_img_h(im)); + snprintf(name, sizeof name, "%s 峰值 ≤ 2.5 MiB 且非空", fx[i].tag); + CHECK(im && nonblank(im) && pk <= PEAK_2500KB, name); + } + meow_img_free(im); + } + free(pe); free(p4); free(pg); free(pp); free(pq); + } + + /* ---- S3 progressive 2000×3000 ---- */ + printf("[S3] progressive 2000x3000\n"); + { + meow_img *im = NULL; + size_t pk = peak_of_dim(p_prog, 720, &im); + peak_report("新入口 _dim(720) progressive", pk); + CHECK(im && meow_img_w(im) == 480 && meow_img_h(im) == 720 && nonblank(im), + "结果 480x720 且非空"); + CHECK(pk <= PEAK_2500KB, "渐进式峰值 ≤ 2.5 MiB(不整幅驻留系数)"); + { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(p_prog); + size_t pk_old = peak_since(base); + peak_report("旧入口 progressive", pk_old); + CHECK(old && pk_old > 8 * pk, "渐进式新入口峰值 < 旧入口 1/8"); + meow_img_free(old); + } + meow_img_free(im); + } + + /* ---- S4 PNG ---- */ + printf("[S4] PNG 1600x2400\n"); + { + meow_img *im = NULL; + size_t pk = peak_of_dim(p_png, 720, &im); + peak_report("新入口 _dim(720) PNG", pk); + CHECK(im && meow_img_w(im) == 480 && meow_img_h(im) == 720 && nonblank(im), + "结果 480x720 且非空"); + CHECK(pk <= PEAK_2500KB, "PNG 峰值 ≤ 2.5 MiB(逐行 inflate)"); + { + size_t base = peak_reset(); + meow_img *old = meow_img_load_file(p_png); + size_t pk_old = peak_since(base); + peak_report("旧入口 PNG", pk_old); + CHECK(old && pk_old > 6 * pk, "PNG 新入口峰值 < 旧入口 1/6"); + meow_img_free(old); + } + meow_img_free(im); + } + + /* ---- S5 BMP ---- */ + printf("[S5] BMP 1600x2400\n"); + { + meow_img *im = NULL; + size_t pk = peak_of_dim(p_bmp, 720, &im); + peak_report("新入口 _dim(720) BMP", pk); + CHECK(im && meow_img_w(im) == 480 && meow_img_h(im) == 720 && nonblank(im), + "结果 480x720 且非空"); + CHECK(pk <= PEAK_2500KB, "BMP 峰值 ≤ 2.5 MiB(按行 seek)"); + meow_img_free(im); + } + + /* ---- S6 4/8/12MP:峰值不随分辨率线性增长 ---- */ + printf("[S6] 4MP/8MP/12MP JPEG(同一 maxdim=720)\n"); + { + size_t p4 = peak_of_dim(p_mp4, 720, NULL); + size_t p8 = peak_of_dim(p_mp8, 720, NULL); + size_t p12 = peak_of_dim(p_mp12, 720, NULL); + meow_img *big = NULL; + size_t pb = peak_of_dim(p_mp12, 720, &big); + (void)pb; + peak_report("4MP _dim(720)", p4); + peak_report("8MP _dim(720)", p8); + peak_report("12MP _dim(720)", p12); + CHECK(big && nonblank(big), "12MP 能解且非空"); + meow_img_free(big); + CHECK(p4 && p8 && p12, "4/8/12MP 全部解码成功"); + CHECK(p4 <= PEAK_2500KB && p8 <= PEAK_2500KB && p12 <= PEAK_2500KB, + "4/8/12MP 峰值均 ≤ 2.5 MiB"); + /* 像素数 3× 增长(4→12MP),线性则峰值 3×;DCT 缩解下应远小于。 */ + CHECK(p12 < p4 * 2, "12MP/4MP 峰值 < 2×(远非线性 3×)"); + printf(" 12MP/4MP 峰值比 = %.2f(像素比 3.00,旧入口约同上)\n", + (double)p12 / (double)p4); + } + + /* ---- S6b 前后对比表 ---- */ + printf("[S6b] 峰值前后对比(旧 _dim 路径 = stbi_load_scaled + 精修)\n"); + { + struct { const char *tag; const char *path; int md; } fx[] = { + { "2000x3000 baseline @720", p_base, 720 }, + { "2000x3000 progressive @720", p_prog, 720 }, + { "2000x3000 baseline @480", p_base, 480 }, + { "1600x2400 PNG @720", p_png, 720 }, + { "1600x2400 BMP @720", p_bmp, 720 }, + { "4MP JPEG @720", p_mp4, 720 }, + { "8MP JPEG @720", p_mp8, 720 }, + { "12MP JPEG @720", p_mp12, 720 }, + }; + for (size_t i = 0; i < sizeof fx / sizeof fx[0]; i++) { + size_t old = old_dim_peak(fx[i].path, fx[i].md); + meow_img *im = NULL; + size_t nw = peak_of_dim(fx[i].path, fx[i].md, &im); + char name[128]; + printf(" %-28s 旧 %6.2f MiB → 新 %5.2f MiB(÷%.1f)\n", fx[i].tag, + (double)old / 1048576.0, (double)nw / 1048576.0, + nw ? (double)old / (double)nw : 0.0); + snprintf(name, sizeof name, "%s 新峰值 ≤ 旧 1/3", fx[i].tag); + CHECK(old && nw && nw * 3 <= old, name); + meow_img_free(im); + } + } + + /* ---- S7 像素一致性 ---- */ + printf("[S7] 像素一致性(vs stb 整幅 + meow_img_downscale)\n"); + { + struct { const char *tag; const char *path; int md; double tol; double corr_min; } cases[] = { + { "baseline JPEG", p_base, 720, 24.0, 0.85 }, + { "progressive JPEG", p_prog, 720, 24.0, 0.85 }, + { "baseline JPEG", p_base, 300, 26.0, 0.85 }, + { "4:4:4 JPEG", p_444, 720, 24.0, 0.85 }, + { "4:2:2 JPEG", p_422, 720, 24.0, 0.85 }, + { "gray JPEG", p_grayjpg, 720, 24.0, 0.85 }, + { "photo baseline", p_photo, 720, 24.0, 0.85 }, + { "photo progressive", p_photop, 720, 26.0, 0.85 }, + { "PNG RGB", p_png, 720, 8.0, 0.999 }, + { "PNG gray", p_gray, 300, 8.0, 0.999 }, + { "PNG palette", p_pal, 300, 8.0, 0.999 }, + { "PNG RGBA", p_rgba, 300, 8.0, 0.999 }, + { "BMP", p_bmp, 720, 8.0, 0.999 }, + { "12MP JPEG", p_mp12, 720, 24.0, 0.85 }, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { + meow_img *got = meow_img_load_file_dim(cases[i].path, cases[i].md); + meow_img *ref = ref_thumb(cases[i].path, cases[i].md); + char name[128]; + if (!got || !ref) { + snprintf(name, sizeof name, "%s 两侧都成功", cases[i].tag); + CHECK(0, name); + meow_img_free(got); meow_img_free(ref); + continue; + } + { + double corr = 0.0; + double d = mean_abs_diff(got, ref, 64, &corr); + snprintf(name, sizeof name, "%s _dim(%d) 平均差 %.2f < %.1f", + cases[i].tag, cases[i].md, d, cases[i].tol); + CHECK(d < cases[i].tol, name); + snprintf(name, sizeof name, "%s _dim(%d) 结构相关 %.3f > %.3f(非错位/反色/错位通道)", + cases[i].tag, cases[i].md, corr, cases[i].corr_min); + CHECK(corr > cases[i].corr_min, name); + snprintf(name, sizeof name, "%s _dim(%d) 尺寸与旧入口精修一致", + cases[i].tag, cases[i].md); + CHECK(meow_img_w(got) == meow_img_w(ref) && meow_img_h(got) == meow_img_h(ref), + name); + } + meow_img_free(got); + meow_img_free(ref); + } + } + + /* ---- S10 缩解阶扫描 + 重启标记(验证 N=4/5/7 基表与 RST 重同步) ---- */ + printf("[S10] 缩解阶扫描(N=4/5/7)与 restart marker\n"); + { + char *psw = path_join("sweep640.jpg"); + char *prs = path_join("restart2000.jpg"); + static const int dims[] = { 320, 400, 560 }; + for (size_t i = 0; i < sizeof dims / sizeof dims[0]; i++) { + meow_img *got = meow_img_load_file_dim(psw, dims[i]); + meow_img *ref = ref_thumb(psw, dims[i]); + char name[128]; + double corr = 0.0; + double d = (got && ref) ? mean_abs_diff(got, ref, 32, &corr) : 1e9; + snprintf(name, sizeof name, "640x480 _dim(%d) → %dx%d 平均差 %.2f 相关 %.3f", + dims[i], meow_img_w(got), meow_img_h(got), d, corr); + CHECK(got && ref && d < 24.0 && corr > 0.85 && nonblank(got), name); + meow_img_free(got); + meow_img_free(ref); + } + { + size_t base = peak_reset(); + meow_img *got = meow_img_load_file_dim(prs, 720); + size_t pk = peak_since(base); + meow_img *ref = ref_thumb(prs, 720); + double corr = 0.0; + double d = (got && ref) ? mean_abs_diff(got, ref, 64, &corr) : 1e9; + peak_report("restart marker 2000x3000", pk); + CHECK(got && nonblank(got), "restart marker JPEG 解码成功"); + CHECK(d < 24.0 && corr > 0.85, "restart marker JPEG 与 stb 结构一致(RST 重同步正确)"); + CHECK(pk <= PEAK_2500KB, "restart marker JPEG 峰值 ≤ 2.5 MiB"); + meow_img_free(got); + meow_img_free(ref); + } + free(psw); + free(prs); + } + + /* ---- S8 内存入口 ---- */ + printf("[S8] 内存入口 meow_img_load_mem_dim\n"); + { + size_t len = 0; + uint8_t *buf = read_whole(p_base, &len); + CHECK(buf != NULL, "读入 baseline JPEG"); + if (buf) { + size_t base = peak_reset(); + meow_img *m = meow_img_load_mem_dim(buf, (int)len, 720); + size_t pk = peak_since(base); + meow_img *f = meow_img_load_file_dim(p_base, 720); + peak_report("_mem_dim(720)", pk); + CHECK(m && f && meow_img_w(m) == meow_img_w(f) && meow_img_h(m) == meow_img_h(f), + "内存入口尺寸一致"); + CHECK(m && f && memcmp(meow_img_pixels(m), meow_img_pixels(f), + (size_t)meow_img_w(f) * meow_img_h(f) * 4) == 0, "内存入口逐字节一致"); + CHECK(pk <= PEAK_2500KB, "内存入口峰值 ≤ 2.5 MiB"); + meow_img_free(m); + meow_img_free(f); + free(buf); + } + } + + /* ---- S9 回退与容错 ---- */ + printf("[S9] 回退(新后端拒绝 → stb)与坏图\n"); + { + int tw = 0, th = 0; + unsigned char *px = NULL; + meow_img *im; + /* CMYK(4 分量):后端必须拒绝,stb 必须兜住 */ + CHECK(meow_img_backend_file(p_cmyk, 720, &tw, &th, &px) == 0 && px == NULL, + "CMYK JPEG → 后端拒绝"); + im = meow_img_load_file_dim(p_cmyk, 720); + CHECK(im && nonblank(im), "CMYK JPEG → stb 回退成功"); + meow_img_free(im); + /* 1bpp BMP */ + CHECK(meow_img_backend_file(p_mono, 720, &tw, &th, &px) == 0 && px == NULL, + "1bpp BMP → 后端拒绝"); + im = meow_img_load_file_dim(p_mono, 720); + CHECK(im && nonblank(im), "1bpp BMP → stb 回退成功"); + meow_img_free(im); + /* Adam7 交错 PNG */ + CHECK(meow_img_backend_file(p_adam7, 720, &tw, &th, &px) == 0 && px == NULL, + "Adam7 PNG → 后端拒绝"); + im = meow_img_load_file_dim(p_adam7, 720); + CHECK(im && nonblank(im), "Adam7 PNG → stb 回退成功"); + meow_img_free(im); + /* 坏图 */ + CHECK(meow_img_load_file_dim(p_broken, 480) == NULL, "截断 JPEG → NULL"); + CHECK(meow_img_load_file_dim("/no/such/file.jpg", 480) == NULL, "不存在路径 → NULL"); + CHECK(meow_img_load_file_dim(NULL, 480) == NULL, "NULL 路径 → NULL"); + CHECK(meow_img_load_mem_dim("x", 3, 480) == NULL, "垃圾内存 → NULL"); + } + + if (g_fail) printf("\nRESULT: FAIL (%d)\n", g_fail); + else printf("\nRESULT: ALL PASS\n"); + free(p_base); free(p_prog); free(p_png); free(p_bmp); + free(p_mp4); free(p_mp8); free(p_mp12); free(p_small); + free(p_gray); free(p_pal); free(p_rgba); free(p_cmyk); + free(p_mono); free(p_adam7); free(p_broken); + free(p_444); free(p_422); free(p_grayjpg); free(p_photo); free(p_photop); + return g_fail ? 1 : 0; +} From e9c0749609f0e6ee9de52e74e7e63a6513fc1d6f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 21:08:02 +0800 Subject: [PATCH 24/38] =?UTF-8?q?test:=20=E5=AE=BF=E4=B8=BB=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E8=A1=8C=E8=B7=9F=E8=BF=9B=20M23=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=20TU=EF=BC=88meow=5Fjpeg=20/=20meow=5Fpng=5Fstream=20+=20-lz?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tuku/test/run_tests.sh 与 run_webtoon_tests.sh 里直接编译 meow_ui_img.c 的 6 条 gcc 行补上 "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c;settings_lock_host 那条 原来没有 -lz(PNG 后端的 inflate 需要)。sdk/test_meow_img_color.c 头注释里的 编译命令同步(顺带补上 M21 起就缺的 meow_ui_time.c)。 只动链接行/注释,不改测试断言。 --- sdk/test_meow_img_color.c | 10 ++++++---- tuku/test/run_tests.sh | 7 ++++++- tuku/test/run_webtoon_tests.sh | 1 + 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/sdk/test_meow_img_color.c b/sdk/test_meow_img_color.c index 0f23048..da8d71e 100644 --- a/sdk/test_meow_img_color.c +++ b/sdk/test_meow_img_color.c @@ -10,14 +10,16 @@ * C7 set_colors 注入/回退(lyric_view 层):注入 derive.lyr → cur=fg、gray 随 cur; * NULL 回退 meow_theme 默认 * - * 编译(在 patch_v2/sdk 下;链法同 test_meow_lyric_view + 单链 meow_img_color.c): + * 编译(在 sdk 目录下;链法同 test_meow_lyric_view + 单链 meow_img_color.c; + * meow_ui_time.c 提供 meow_ui_time_ms,meow_jpeg.c/meow_png_stream.c 为 M23 解码后端,-lz 供其 inflate): * gcc -Wall -Wextra -o test_meow_img_color test_meow_img_color.c test_host_stub.c \ - * meow_img_color.c meow_lyric.c meow_lyric_view.c \ + * meow_img_color.c meow_lyric.c meow_lyric_view.c meow_ui_time.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \ - * meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c meow_ui_nav.c \ - * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm + * meow_ui_img.c meow_jpeg.c meow_png_stream.c meow_ui_media.c meow_notify_ipc.c \ + * stb_image.c meow_ui_nav.c \ + * -I. -Iinclude -DMEOW_UI_HOST_TEST -pthread -lm -lz * 运行:./test_meow_img_color */ #include "meow_ui.h" diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 7ea49c6..d27e491 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -72,9 +72,10 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ - -pthread -lm -o "$OUT"/test_settings_lock_host + -pthread -lm -lz -o "$OUT"/test_settings_lock_host "$OUT"/test_settings_lock_host # 书库浏览页集成(文件管理器行模型/点击语义/根锁定/无 SD 卡空态/零 IO 行合成);同样需要 SDK 源码树。 @@ -90,6 +91,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_browse_ui_host @@ -108,6 +110,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_open_book_host @@ -125,6 +128,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_reader_perf @@ -142,6 +146,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_reader_many_pages diff --git a/tuku/test/run_webtoon_tests.sh b/tuku/test/run_webtoon_tests.sh index bc945bb..9682824 100755 --- a/tuku/test/run_webtoon_tests.sh +++ b/tuku/test/run_webtoon_tests.sh @@ -40,6 +40,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT/test_mode_switch_host" From 11a4fcd73049967083a00796a6b9f73a1b302bb0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 22:39:09 +0800 Subject: [PATCH 25/38] =?UTF-8?q?fix:=20=E7=95=B8=E5=BD=A2=20DHT=20?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=20+=20=E6=B8=90=E8=BF=9B=E5=BC=8F=20restart?= =?UTF-8?q?=20=E9=87=8D=E5=90=8C=E6=AD=A5=EF=BC=88M23=20r1=20P1-1/P1-2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1(内存安全):mj_huff_build 构造 9 位快表前校验规范码空间 (code + bits[l] > (1<0)——低频带已完整,不再整幅驻留系数阵列; + * JPEG:DCT 域缩解(每 8×8 块只解出 N×N 像素,N 由 8·maxdim/长边 取「不欠采样」的最小值; + * N=1..7,N≥8 交回 stb),渐进式跳过精细化扫描、只解各频带首次扫描 —— 低频带已完整, + * 不整幅驻留系数阵列; * PNG :libz 逐行 inflate + 反滤波,按步长丢行丢列直写目标尺寸(gray/gray+A/RGB/RGBA/ - * 调色板/16bit,16bit 取高字节); + * 调色板/1-16bit,16bit 取高字节,tRNS 透明含 8bit 灰/RGB); * BMP :按目标行 seek 到源行 + 行内跳列(8bpp 调色板 / 16bpp RGB555 / 24bpp / 32bpp)。 * 峰值只与输出尺寸相关,不随源图分辨率/压缩体积线性增长(2000×3000 页 ≤2.5MB 量级)。 - * 不支持的特性(CMYK/YCCK 4 分量 JPEG、算术编码、Adam7 交错 PNG、RLE/1-4bpp BMP、几乎等尺寸) - * 自动回退 stb 旧路径(meow_img_load_file + 缩解/降采样),对外语义完全不变。 - * 小图(长边 ≤ maxdim)走旧路径,与原 API 完全一致;maxdim<=0 等价旧入口。失败返回 NULL。 */ + * 不支持的特性(CMYK/YCCK 4 分量 JPEG、算术编码、过订阅 Huffman 表、16bit 量化表、Adam7 + * 交错 PNG、RLE/1-4bpp BMP、几乎等尺寸)自动回退 stb 旧路径,对外语义完全不变。 + * 小图(长边 ≤ maxdim)走旧路径,与原 API 完全一致;maxdim<=0 等价旧入口。失败返回 NULL。 + * + * 尺寸口径说明(M23 评审 P2-2):本入口按「原图 + meow_img_downscale」公式算目标尺寸;M12 旧 + * 实现经 stb 的 ceil(w/s) 缩解再精修,源尺寸不是档位整数倍时可能与本入口差 1px(实测 1999×2999: + * 旧 320×480 vs 新 319×480)。阅读器按显示框缩放,无实际影响;PNG/BMP 无此差异。 */ meow_img *meow_img_load_file_dim(const char *path, int maxdim); meow_img *meow_img_load_mem_dim(const void *buf, int len, int maxdim); @@ -51,7 +56,9 @@ int meow_img_pick_scale(int longside, int maxdim); /* 追加(M22)显式档位解码:语义与 meow_img_load_file_dim 相同(结果长边 ≤ maxdim, * 超了就再精修一次),只是缩解档位由调用方决定。内存吃紧时可以选更粗的档位——输出更小、 * 峰值更低,代价是细节变软。step <= 1 等价长边约束入口;step > 8 按 8 处理。 - * 非 JPEG 格式 stb 没有解码期缩放,step 被忽略(整幅解码)。 */ + * M23 起:JPEG 先走真缩解后端(step 折算成目标长边 ceil(长边/step),后端按该长边选 DCT 阶), + * 峰值只与输出尺寸相关;不支持的特性回退 stb(step 原样传入)。PNG/BMP 暂仍走 stb —— 阅读器的 + * 单次解码预算模型(M22)还是「整幅物化」口径,待 M27 同步后放开(见 M23 r1 报告)。 */ meow_img *meow_img_load_file_step(const char *path, int maxdim, int step); /* 追加(M22)精确重采样到 w×h(最近邻,缩小/放大都支持)。阅读器用它把「缩解档位」的 diff --git a/sdk/meow_jpeg.c b/sdk/meow_jpeg.c index 4b05372..934056c 100644 --- a/sdk/meow_jpeg.c +++ b/sdk/meow_jpeg.c @@ -148,21 +148,26 @@ typedef struct { int valptr[17]; } mj_huff; -static void mj_huff_build(mj_huff *h) +/* 构造 9 位快表 + 规范码表。返回 0 = 表非法(过订阅/码空间越界),调用方必须拒绝该表。 + * 过订阅输入(如「长度 2 的码有 100 个」)此前会让 code << shift 把快表下标推到 512 之外, + * 28~220 字节的畸形 JPEG 就能栈溢出 → 这里在填表前校验码空间,填表时再加一道下标边界。 */ +static int mj_huff_build(mj_huff *h) { int code = 0, k = 0, l; memset(h->fast, 0, sizeof h->fast); h->maxcode[0] = -1; for (l = 1; l <= 16; l++) { int i; + if (code + h->bits[l] > (1 << l)) return 0; /* 码空间溢出:过订阅表 */ h->valptr[l] = k; h->mincode[l] = code; for (i = 0; i < h->bits[l]; i++) { if (l <= 9) { int shift = 9 - l, j; unsigned short e = (unsigned short)((l << 8) | h->vals[k]); + int base = code << shift; for (j = 0; j < (1 << shift); j++) - h->fast[(code << shift) | j] = e; + if ((base | j) < 512) h->fast[base | j] = e; } code++; k++; @@ -170,6 +175,7 @@ static void mj_huff_build(mj_huff *h) h->maxcode[l] = h->bits[l] ? code - 1 : -1; code <<= 1; } + return 1; } static int mj_hdecode(mj_src *s, const mj_huff *h) @@ -341,16 +347,12 @@ static int mj_dqt(mj_img *img, const unsigned char *d, int len) int pq = d[p] >> 4, tq = d[p] & 15, i; p++; if (tq > 3) return 0; - if (pq == 0) { - if (p + 64 > len) return 0; - for (i = 0; i < 64; i++) img->qt[tq][mj_zigzag[i]] = d[p + i]; - p += 64; - } else { - if (p + 128 > len) return 0; - for (i = 0; i < 64; i++) - img->qt[tq][mj_zigzag[i]] = (d[p + 2 * i] << 8) | d[p + 2 * i + 1]; - p += 128; - } + /* 16bit 量化表只出现在 12bit 精度(本模块不支持);收下会让 F*qt 在 mj_accum 里 + * int32 溢出(UB)。直接拒绝 → 后端返回 0 → 回退 stb。 */ + if (pq != 0) return 0; + if (p + 64 > len) return 0; + for (i = 0; i < 64; i++) img->qt[tq][mj_zigzag[i]] = d[p + i]; + p += 64; img->have_qt[tq] = 1; } return 1; @@ -371,7 +373,7 @@ static int mj_dht(mj_img *img, const unsigned char *d, int len) if (total > 256 || p + total > len) return 0; for (i = 0; i < total; i++) h->vals[i] = d[p + i]; p += total; - mj_huff_build(h); + if (!mj_huff_build(h)) return 0; /* 过订阅/码空间越界:拒绝整段 → 回退 stb */ if (tc) img->have_ac[th] = 1; else img->have_dc[th] = 1; } } @@ -558,7 +560,10 @@ static void mj_skip_entropy(mj_img *img) } } -/* 重启标记:对齐位缓冲并吃掉 RSTn,重置 DC 预测。 */ +/* 重启标记:对齐位缓冲并吃掉 RSTn,重置 DC 预测。 + * + * 裸扫时必须跳过 FF 00(数据字节 0xFF 的填充):区间末尾的填充字节常常正好是 0xFF, + * 它在码流里写成 `FF 00 FF Dn`;把中间那个 00 当成 marker 会让扫描提前结束并失步。 */ static void mj_restart(mj_img *img) { mj_src *s = &img->src; @@ -573,14 +578,47 @@ static void mj_restart(mj_img *img) c = mj_raw(s); if (c < 0) { s->over = 1; break; } if (c != 0xFF) continue; - do { c = mj_raw(s); if (c < 0) { s->over = 1; break; } } while (c == 0xFF); - if (c < 0) break; - if (c >= 0xD0 && c <= 0xD7) break; - s->marker = c; s->over = 1; - break; + for (;;) { + c = mj_raw(s); + if (c < 0) { s->over = 1; break; } + if (c == 0xFF) continue; /* 填充 FF */ + if (c >= 0xD0 && c <= 0xD7) goto rst_found; + if (c == 0x00) break; /* FF00 = 数据字节,继续扫 */ + s->marker = c; s->over = 1; /* 别的 marker:熵段结束 */ + goto rst_done; + } + if (s->over) break; } } +rst_found: for (i = 0; i < img->ncomp; i++) img->c[i].pred = 0; +rst_done: + ; +} + +/* 区间边界处理(三处扫描循环共用)。返回:0 = 熵段结束(调用方退出扫描); + * 1 = 继续;2 = 刚做过区间重同步(AC 扫描需清 EOBRUN)。 + * + * 只在 total == rst(编码器的区间长度)时重同步。**不能**一看到 over&&RSTn 就重同步: + * 熵码位缓冲有最多 9 位前瞻,`over` 会在区间真正结束前 1~2 个 MCU 就被置位, + * 此时 bitbuf 里还压着本区间剩余块的有效比特——提前重同步会把它们丢掉并失步 + * (渐进式 DC 扫描每块仅 ~1 bit,前瞻能跨过整个 MCU;这是 rst=2..200 花屏的根因)。 + * 到达 total==rst 时,RSTn 要么已被前瞻吃掉(over/marker=RSTn,清掉即可), + * 要么还在流里(裸扫对齐)——mj_restart 两种都处理。 */ +static int mj_boundary(mj_img *img, int *total) +{ + mj_src *s = &img->src; + if (img->rst && *total == img->rst) { + mj_restart(img); + *total = 0; + return 2; + } + if (s->over) { + /* 前瞻已吃到 RSTn:本区间剩余块的有效比特还在 bitbuf 里,继续解。 + * 只有「非 RST 的 marker」才是熵段真的结束(EOI/下一段的 DHT…)。 */ + return (s->marker >= 0xD0 && s->marker <= 0xD7) ? 1 : 0; + } + return 1; } typedef struct { @@ -600,12 +638,7 @@ static void mj_scan_seq(mj_img *img, const mj_scan *sc) for (my = 0; my < mcus_y; my++) { for (mx = 0; mx < mcus_x; mx++) { int j; - if (img->rst && total == img->rst) { - if (img->src.over && !(img->src.marker >= 0xD0 && img->src.marker <= 0xD7)) return; - mj_restart(img); - total = 0; - } - if (img->src.over) return; + if (!mj_boundary(img, &total)) return; if (single) { mj_comp *c = &img->c[sc->ci[0]]; c->td = sc->td[0]; c->ta = sc->ta[0]; @@ -635,12 +668,9 @@ static void mj_scan_dc_prog(mj_img *img, const mj_scan *sc) for (my = 0; my < mcus_y; my++) { for (mx = 0; mx < mcus_x; mx++) { int j; - if (img->rst && total == img->rst) { - if (img->src.over && !(img->src.marker >= 0xD0 && img->src.marker <= 0xD7)) return; - mj_restart(img); - total = 0; + if (!mj_boundary(img, &total)) { + return; } - if (img->src.over) return; if (single) { mj_comp *c = &img->c[sc->ci[0]]; c->td = sc->td[0]; @@ -669,13 +699,9 @@ static void mj_scan_ac_prog(mj_img *img, const mj_scan *sc) c->ta = sc->ta[0]; for (by = 0; by < c->oh; by++) { for (bx = 0; bx < c->ow; bx++) { - if (img->rst && total == img->rst) { - if (img->src.over && !(img->src.marker >= 0xD0 && img->src.marker <= 0xD7)) return; - mj_restart(img); - total = 0; - eobrun = 0; - } - if (s->over) return; + int rb = mj_boundary(img, &total); + if (!rb) return; + if (rb == 2) eobrun = 0; /* 区间重同步:EOB 游程不能跨 RST */ if (eobrun > 0) { eobrun--; } else { @@ -709,7 +735,9 @@ static void mj_scan_ac_prog(mj_img *img, const mj_scan *sc) } total++; img->blocks++; - if (s->over) return; + /* 这里**不能**再 `if (s->over) return`:块末的位前瞻可能已吃掉本区间的 RSTn + * (over=1),必须留给下一轮 mj_boundary 做区间重同步,否则渐进式 + restart 的 + * AC 扫描会在第一个区间边界处静默退出、把后续 RST 当独立 marker 跳过 → 失步。 */ } } } @@ -838,6 +866,7 @@ static int mj_decode(mj_img *img, int maxdim, int *tw, int *th, unsigned char ** } continue; } + { if (!img->progressive) { mj_scan_seq(img, &sc); } else if (sc.ss == 0) { @@ -846,6 +875,7 @@ static int mj_decode(mj_img *img, int maxdim, int *tw, int *th, unsigned char ** if (sc.ns != 1) return 0; mj_scan_ac_prog(img, &sc); } + } s->bitcnt = 0; s->bitbuf = 0; /* 一个熵段结束:位读取器可能已把下一个 marker 吃掉,回填给主循环, diff --git a/sdk/meow_png_stream.c b/sdk/meow_png_stream.c index b505191..4da2594 100644 --- a/sdk/meow_png_stream.c +++ b/sdk/meow_png_stream.c @@ -128,17 +128,19 @@ static void png_get_px(const png_ctx *c, const unsigned char *row, int x, unsign } else { unsigned char g = (unsigned char)(v * 255 / ((1 << d) - 1)); o[0] = o[1] = o[2] = g; - o[3] = 255; + /* tRNS 存的是原始采样值(0..2^d-1),不是展开后的 0..255 */ + o[3] = (c->color == 0 && c->have_trns && (unsigned)v == c->trns[0]) ? 0 : 255; } return; } if (d == 8) { if (c->color == 0) { /* gray */ o[0] = o[1] = o[2] = row[x]; - o[3] = 255; + o[3] = (c->have_trns && row[x] == c->trns[0]) ? 0 : 255; } else if (c->color == 2) { /* RGB */ o[0] = row[x * 3]; o[1] = row[x * 3 + 1]; o[2] = row[x * 3 + 2]; - o[3] = 255; + o[3] = (c->have_trns && row[x * 3] == c->trns[0] && + row[x * 3 + 1] == c->trns[1] && row[x * 3 + 2] == c->trns[2]) ? 0 : 255; } else if (c->color == 3) { /* palette */ int i = row[x]; o[0] = c->pal[i][0]; o[1] = c->pal[i][1]; o[2] = c->pal[i][2]; @@ -333,9 +335,14 @@ int meow_png_decode_stream(FILE *f, const unsigned char *mem, size_t len, if (c.color == 3) { for (i = 0; i < (int)clen && i < 256; i++) c.trns_a[i] = d[i]; } else if (c.color == 0 && clen >= 2) { - c.trns[0] = d[0]; /* 高字节(已降 8bit) */ + /* 8bit/低位深:采样值存在低字节;16bit:与像素一样取高字节比较 */ + c.trns[0] = (c.depth == 16) ? d[0] : d[1]; } else if (c.color == 2 && clen >= 6) { - c.trns[0] = d[0]; c.trns[1] = d[2]; c.trns[2] = d[4]; + if (c.depth == 16) { + c.trns[0] = d[0]; c.trns[1] = d[2]; c.trns[2] = d[4]; + } else { + c.trns[0] = d[1]; c.trns[1] = d[3]; c.trns[2] = d[5]; + } } } else if (memcmp(type, "IDAT", 4) == 0) { unsigned remain = clen; diff --git a/sdk/meow_ui_img.c b/sdk/meow_ui_img.c index 8f20682..2a8c271 100644 --- a/sdk/meow_ui_img.c +++ b/sdk/meow_ui_img.c @@ -110,6 +110,34 @@ meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) if (!stbi_info(path, &w, &h, &c)) return NULL; int longside = w > h ? w : h; if (step == 1 && maxdim <= 0) return meow_img_load_file(path); + /* 追加(M23):真缩解后端优先(本入口只接 JPEG)。step 是「输出别比这个档位更细」的提示: + * 折算成目标长边 ceil(长边/step) 传给后端(后端按该长边自动选 DCT 阶),与下面 + * stbi_load_scaled(step) 的语义一致,峰值只与输出尺寸相关;maxdim<=0 时不精修, + * 结果仍是档位尺寸。后端不支持(CMYK/算术编码/交错…)时回退 stb,行为与 M22 一致。 + * + * PNG/BMP 在本入口暂不接后端:阅读器的单次解码峰值**预算模型**还是 M22 的「整幅物化」 + * 口径,实测峰值骤降会让它的 model≤2×measured 自检失真(详见 M23 r1 报告的 M27 待办)。 + * 待 M27 同步模型后,把这里的 JPEG 判定去掉即可。 */ + { + unsigned char sig[3]; + int is_jpeg = 0; + FILE *fp = fopen(path, "rb"); + if (fp) { + if (fread(sig, 1, 3, fp) == 3 && + sig[0] == 0xFF && sig[1] == 0xD8 && sig[2] == 0xFF) + is_jpeg = 1; + fclose(fp); + } + if (is_jpeg) { + int eff = maxdim > 0 ? maxdim : longside; + int step_cap = (longside + step - 1) / step; + unsigned char *bp = NULL; + int bw = 0, bh = 0; + if (step_cap > 0 && step_cap < eff) eff = step_cap; + if (eff > 0 && meow_img_backend_file(path, eff, &bw, &bh, &bp) && bp) + return img_finish_dim(bp, bw, bh, maxdim > 0 ? maxdim : longside); + } + } unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, step); /* maxdim<=0:不精修(保留档位输出的原始尺寸)。 */ return img_finish_dim(p, w, h, maxdim > 0 ? maxdim : longside); diff --git a/sdk/test_meow_img_stream.c b/sdk/test_meow_img_stream.c index 509b3ac..2cfabad 100644 --- a/sdk/test_meow_img_stream.c +++ b/sdk/test_meow_img_stream.c @@ -180,6 +180,17 @@ static const char *g_gen_py = " chunk(b'IDAT', zlib.compress(bytes(raw))) + chunk(b'IEND', b''))\n" " open(path, 'wb').write(data)\n" "adam7(os.path.join(out, 'adam7.png'), 1600, 2400)\n" +"for rb in (1, 2, 3, 4, 5, 8, 16, 64, 200):\n" +" big.save(os.path.join(out, 'prog_rst%d.jpg' % rb), 'JPEG', quality=60,\n" +" subsampling=2, progressive=True, restart_marker_blocks=rb)\n" +"g8 = Image.new('L', (640, 480), 0)\n" +"dg = ImageDraw.Draw(g8)\n" +"dg.rectangle([100, 100, 400, 300], fill=255)\n" +"g8.save(os.path.join(out, 'gray8_trns.png'), 'PNG', transparency=0)\n" +"r8 = Image.new('RGB', (640, 480), (255, 0, 255))\n" +"dr = ImageDraw.Draw(r8)\n" +"dr.rectangle([100, 100, 400, 300], fill=(10, 200, 30))\n" +"r8.save(os.path.join(out, 'rgb8_trns.png'), 'PNG', transparency=(255, 0, 255))\n" "open(os.path.join(out, 'broken.jpg'), 'wb').write(b'\\xff\\xd8\\xff\\xe0' +\n" " bytes((i * 37 + 11) & 0xFF for i in range(256)))\n" "for n in sorted(os.listdir(out)):\n" @@ -390,6 +401,75 @@ static size_t old_dim_peak(const char *path, int maxdim) return peak; } +static long alpha_zero_count(const meow_img *im) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *p = meow_img_pixels(im); + long n = 0; + if (!p) return 0; + for (long i = 0; i < (long)w * h; i++) if (p[i * 4 + 3] == 0) n++; + return n; +} + +/* 目标图 vs 整幅参考图的 alpha 一致性:按 nearest 反向映射采样源像素,返回不一致像素数 + * (-1 = 参数错误)。PNG 的采样公式与后端一致(tx*W/tw、ty*H/th),应逐点相等。 */ +static long alpha_mismatch(const meow_img *got, const meow_img *ref) +{ + int gw = meow_img_w(got), gh = meow_img_h(got); + int rw = meow_img_w(ref), rh = meow_img_h(ref); + const unsigned char *pg = meow_img_pixels(got), *pr = meow_img_pixels(ref); + long bad = 0; + if (!pg || !pr || gw <= 0 || gh <= 0) return -1; + for (int y = 0; y < gh; y++) { + int sy = (int)((long)y * rh / gh); + for (int x = 0; x < gw; x++) { + int sx = (int)((long)x * rw / gw); + if (pg[((size_t)y * gw + x) * 4 + 3] != pr[((size_t)sy * rw + sx) * 4 + 3]) + bad++; + } + } + return bad; +} + +/* ------------------------------------------------------------------ */ +/* P1-1 回归:过订阅 Huffman 表(畸形 DHT) */ +/* ------------------------------------------------------------------ */ +/* 构造「SOI + DHT(码长 lc 有 nsym 个符号) + EOI」的最小 JPEG(≤ 280B)。 */ +static size_t build_badhuff(unsigned char *buf, int lc, int nsym) +{ + size_t p = 0; + int i; + int seglen = 2 + 1 + 16 + nsym; /* DHT 长度字段计入自身 2 字节 */ + buf[p++] = 0xFF; buf[p++] = 0xD8; /* SOI */ + buf[p++] = 0xFF; buf[p++] = 0xC4; /* DHT */ + buf[p++] = (unsigned char)(seglen >> 8); + buf[p++] = (unsigned char)(seglen & 0xFF); + buf[p++] = 0x00; /* Tc=0, Th=0 */ + for (i = 1; i <= 16; i++) + buf[p++] = (unsigned char)(i == lc ? nsym : 0); + for (i = 0; i < nsym; i++) buf[p++] = (unsigned char)i; + buf[p++] = 0xFF; buf[p++] = 0xD9; /* EOI */ + return p; +} + +/* 把同一段坏 DHT 插到真实 JPEG 的 SOI 之后(其余字节原样):验证「有效文件 + 畸形 + * 码表」走的是「拒绝 + 回退」,而不是花屏/崩溃。 */ +static unsigned char *inject_badhuff(const unsigned char *orig, size_t n, + int lc, int nsym, size_t *outn) +{ + unsigned char seg[320]; + size_t all = build_badhuff(seg, lc, nsym); + size_t seglen = all - 4; /* 去掉首 FFD8 与尾 FFD9 */ + size_t total = n + seglen; + unsigned char *out = (unsigned char *)malloc(total); + if (!out) return NULL; + memcpy(out, orig, 2); /* SOI */ + memcpy(out + 2, seg + 2, seglen); /* 坏 DHT 段 */ + memcpy(out + 2 + seglen, orig + 2, n - 2); /* 原文件其余部分 */ + *outn = total; + return out; +} + /* ================================================================== */ int main(int argc, char **argv) { @@ -681,22 +761,192 @@ int main(int argc, char **argv) } { size_t base = peak_reset(); - meow_img *got = meow_img_load_file_dim(prs, 720); - size_t pk = peak_since(base); - meow_img *ref = ref_thumb(prs, 720); + int tw = 0, th = 0; + unsigned char *px = NULL; + int handled = meow_img_backend_file(prs, 720, &tw, &th, &px); + meow_img *got; + size_t pk; + meow_img *ref; double corr = 0.0; - double d = (got && ref) ? mean_abs_diff(got, ref, 64, &corr) : 1e9; + double d; + if (px) free(px); + base = peak_reset(); + got = meow_img_load_file_dim(prs, 720); + pk = peak_since(base); + ref = ref_thumb(prs, 720); + d = (got && ref) ? mean_abs_diff(got, ref, 64, &corr) : 1e9; peak_report("restart marker 2000x3000", pk); + CHECK(handled == 1, "baseline + restart 走后端(BACKEND=1,不回退 stb)"); CHECK(got && nonblank(got), "restart marker JPEG 解码成功"); CHECK(d < 24.0 && corr > 0.85, "restart marker JPEG 与 stb 结构一致(RST 重同步正确)"); CHECK(pk <= PEAK_2500KB, "restart marker JPEG 峰值 ≤ 2.5 MiB"); meow_img_free(got); meow_img_free(ref); } + /* baseline + restart 与同参数无 restart 文件:RST 只切分熵段,解码结果应逐字节相同 */ + { + char *pbase = path_join("base2000.jpg"); + meow_img *a = meow_img_load_file_dim(prs, 720); + meow_img *b = meow_img_load_file_dim(pbase, 720); + CHECK(a && b && meow_img_w(a) == meow_img_w(b) && meow_img_h(a) == meow_img_h(b) && + memcmp(meow_img_pixels(a), meow_img_pixels(b), + (size_t)meow_img_w(a) * meow_img_h(a) * 4) == 0, + "baseline restart 与无 restart 结果逐字节一致(mad=0)"); + meow_img_free(a); + meow_img_free(b); + free(pbase); + } free(psw); free(prs); } + /* ---- S11 畸形 DHT(过订阅 Huffman 表):拒绝 + 回退,不崩 ---- */ + printf("[S11] 畸形 DHT 矩阵(码长 1..4 × 符号数 3..255)\n"); + { + unsigned char buf[320]; + int cases = 0, rejected = 0; + int lc, nsym; + for (lc = 1; lc <= 4; lc++) { + for (nsym = 3; nsym <= 255; nsym++) { + size_t n = build_badhuff(buf, lc, nsym); + int tw = 0, th = 0; + unsigned char *px = NULL; + cases++; + /* 崩溃即测试进程死;返回 1 说明过订阅表被当成合法表收下(缺陷) */ + if (meow_img_backend_mem(buf, n, 720, &tw, &th, &px) == 0) rejected++; + if (px) free(px); + } + } + { + char name[128]; + snprintf(name, sizeof name, "过订阅 DHT 全部拒绝(%d/%d,无崩溃)", rejected, cases); + CHECK(rejected == cases, name); + } + /* 有效 JPEG + 注入坏 DHT:后端必须拒绝;公共入口不得崩(stb 兜底或返回 NULL) */ + { + size_t flen = 0, ilen = 0; + uint8_t *real = read_whole(p_base, &flen); + CHECK(real != NULL, "读入 baseline JPEG 用于注入"); + if (real) { + uint8_t *inj = inject_badhuff(real, flen, 2, 100, &ilen); + int tw = 0, th = 0; + unsigned char *px = NULL; + meow_img *im; + CHECK(inj && meow_img_backend_mem(inj, ilen, 720, &tw, &th, &px) == 0, + "有效 JPEG + 注入过订阅 DHT → 后端拒绝"); + if (px) free(px); + im = inj ? meow_img_load_mem_dim(inj, (int)ilen, 720) : NULL; + CHECK(im == NULL || nonblank(im), "注入坏表后公共入口不崩、不花屏"); + meow_img_free(im); + free(inj); + free(real); + } + } + } + + /* ---- S12 渐进式 + restart marker(P1-2 回归) ---- */ + printf("[S12] progressive + restart marker(BACKEND 必须为 1,峰值 ≤2.5MiB)\n"); + { + static const int rb[] = { 1, 2, 3, 4, 5, 8, 16, 64, 200 }; + meow_img *ref = meow_img_load_file_dim(p_prog, 720); + int all_backend = 1, all_peak = 1, all_equal = 1; + for (size_t i = 0; i < sizeof rb / sizeof rb[0]; i++) { + char fname[48]; + char *p; + int tw = 0, th = 0; + unsigned char *px = NULL; + meow_img *im; + size_t base, pk; + snprintf(fname, sizeof fname, "prog_rst%d.jpg", rb[i]); + p = path_join(fname); + if (meow_img_backend_file(p, 720, &tw, &th, &px) != 1) all_backend = 0; + if (px) free(px); + base = peak_reset(); + im = meow_img_load_file_dim(p, 720); + pk = peak_since(base); + if (!(im && nonblank(im) && pk <= PEAK_2500KB)) all_peak = 0; + if (!(im && ref && meow_img_w(im) == meow_img_w(ref) && meow_img_h(im) == meow_img_h(ref) && + memcmp(meow_img_pixels(im), meow_img_pixels(ref), + (size_t)meow_img_w(ref) * meow_img_h(ref) * 4) == 0)) { + double c2 = 0.0; + double d2 = (im && ref) ? mean_abs_diff(im, ref, 64, &c2) : 1e9; + if (!(d2 < 0.1)) all_equal = 0; /* 允许极个别像素 ±1(编码器分表差异) */ + printf(" RST=%-3d 与无 restart 平均差 %.4f\n", rb[i], d2); + } + /* 关键正确性:与本文件自己的 stb 整幅解码比对(不同编码器脚本下 restart 文件的 + * 系数可能不同,所以拿「无 restart 文件」当基准只能作参考) */ + { + meow_img *rr = ref_thumb(p, 720); + double c3 = 0.0; + double d3 = (im && rr) ? mean_abs_diff(im, rr, 64, &c3) : 1e9; + if (!(d3 < 24.0 && c3 > 0.85)) all_peak = 0; + printf(" RST=%-3d vs stb 平均差 %.2f 相关 %.3f\n", rb[i], d3, c3); + meow_img_free(rr); + } + printf(" RST=%-3d → %dx%d peak %.2f MiB\n", rb[i], + meow_img_w(im), meow_img_h(im), (double)pk / 1048576.0); + free(p); + meow_img_free(im); + } + CHECK(all_backend == 1, "9 个 progressive+restart 全部走后端(BACKEND=1)"); + CHECK(all_peak == 1, "9 个 progressive+restart 全部解码成功且峰值 ≤2.5MiB"); + CHECK(all_equal == 1, "与无 restart 的同编码 progressive 结果逐字节一致(mad=0)"); + meow_img_free(ref); + } + + /* ---- S13 8bit gray/RGB PNG 的 tRNS 透明(P2-1 回归) ---- */ + printf("[S13] PNG 8bit tRNS(gray / RGB)\n"); + { + struct { const char *tag; const char *name; } fx[] = { + { "gray8 tRNS", "gray8_trns.png" }, + { "rgb8 tRNS", "rgb8_trns.png" }, + }; + for (size_t i = 0; i < sizeof fx / sizeof fx[0]; i++) { + char *p = path_join(fx[i].name); + int tw = 0, th = 0; + unsigned char *px = NULL; + int handled = meow_img_backend_file(p, 320, &tw, &th, &px); + meow_img *got; + meow_img *full; + char name[128]; + if (px) free(px); + got = meow_img_load_file_dim(p, 320); + full = meow_img_load_file(p); /* stb 整幅(tRNS 处理正确) */ + snprintf(name, sizeof name, "%s 走后端(BACKEND=1)", fx[i].tag); + CHECK(handled == 1, name); + snprintf(name, sizeof name, "%s 目标图有透明像素(%ld 个)", fx[i].tag, + alpha_zero_count(got)); + CHECK(got && alpha_zero_count(got) > 0, name); + snprintf(name, sizeof name, "%s alpha 与 stb 整幅逐点一致(不一致 %ld)", fx[i].tag, + alpha_mismatch(got, full)); + CHECK(got && full && alpha_mismatch(got, full) == 0, name); + meow_img_free(got); + meow_img_free(full); + free(p); + } + } + + /* ---- S14 M22 显式档位入口 _step:JPEG 走真缩解(PNG/BMP 待 M27 同步预算模型) ---- */ + printf("[S14] meow_img_load_file_step(M22 档位入口)JPEG 走后端\n"); + { + size_t base = peak_reset(); + meow_img *im = meow_img_load_file_step(p_base, 720, 8); + size_t pk = peak_since(base); + char name[128]; + snprintf(name, sizeof name, "_step(720,8) → %dx%d(长边 ≤720,非空)", + meow_img_w(im), meow_img_h(im)); + CHECK(im && nonblank(im) && meow_img_w(im) <= 720 && meow_img_h(im) <= 720, name); + peak_report("_step(720, 8)", pk); + CHECK(pk <= PEAK_2500KB, "_step 峰值 ≤2.5MiB(走后端,不整幅物化)"); + meow_img_free(im); + base = peak_reset(); + im = meow_img_load_file_step(p_base, 720, 1); + pk = peak_since(base); + CHECK(im && nonblank(im) && meow_img_w(im) <= 720, "_step(720,1) 成功且长边 ≤720"); + CHECK(pk <= PEAK_2500KB, "_step(720,1) 峰值 ≤2.5MiB"); + meow_img_free(im); + } + /* ---- S8 内存入口 ---- */ printf("[S8] 内存入口 meow_img_load_mem_dim\n"); { diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index d27e491..89d730d 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -165,6 +165,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c "$SDK"/meow_ui_time.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_reader_membudget \ From 6c8a1d3925577cc5bcac9cd08c50d81fe2bee793 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 11:21:03 +0800 Subject: [PATCH 26/38] =?UTF-8?q?fix:=20=E6=89=AB=E6=8F=8F=E7=BB=93?= =?UTF-8?q?=E5=B0=BE=E4=B8=8D=E5=86=8D=E4=B8=A2=E6=9C=80=E5=90=8E=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=20MCU=20+=20=E7=95=B8=E5=BD=A2=20SOF=20=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E7=A1=AC=E9=97=B8=EF=BC=88M23=20r2=20P1-A/P1-B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-A(像素错误):mj_boundary() 在「over 且 marker 非 RSTn」时 return 0 提前退出 扫描。熵码位缓冲最多 9 位前瞻,会在最后一个 MCU/块**之前**把扫描结束后的 marker (DHT/EOI)吃掉;此时最后 1~2 个 MCU 的有效比特还在 bitbuf 里,return 0 把它们丢掉, 分量平面 memset(128) 的灰底直接露出来(低熵/纯白渐进式右下角 #808080)。 修:扫描循环本来就知道确定的 MCU/块数 —— 一律继续解完(位耗尽后 mj_eb 返回 0 做 零填充),marker 交给外层主循环。实测(纯白 2000x3000 progressive 无 restart): 修复前全图最小通道 128 / 与 stb 最大差 127 / >40 差值的通道 9 个;修复后 最小通道 253~255、最大差 ≤2、>40 通道 0。 测试加固(评审指出旧断言是抽样蒙混): - S12「与无 restart 逐字节一致」改为**全图 memcmp**(去掉 64×64 网格 MAD<0.1 的兜底); 修复后 9 档 restart 全部逐字节一致。 - 新增 S15:纯白 2000x3000 progressive(q50/60/70/80,无 restart)+ mostly-white q95, 断言全图最小通道/右下角 24×24 最小通道 ≥250(丢 MCU 必露 128)、与 stb 全图逐像素比较。 用 HEAD(11a4fcd) 的 meow_jpeg.c 重编同一测试:4 档纯白全部 FAIL(min=128, >40 通道 9),修复后全 PASS。 P1-B(内存放大/安全):mj_sof() 按 SOF 声明尺寸分配平面,降档循环在 base_n 已为 1 时不再生效,17~23 字节的 `SOI+SOF0(65535×65535)+EOI` 会让公开入口 _dim(720) 分配并 memset 64MiB(1 分量)/96MiB(3 分量)后才返回 NULL。 修:降档循环之后补硬闸 `if (plane_bytes + target_bytes > MJ_PEAK_CAP) return 0;` → 回退 stb(同文件 stb 是 0.02MiB 安全失败)。 回归 S16:构造 SOF 65535²(1/3 分量)→ 后端返回 0、公开入口 NULL、峰值 0.02MiB (修复前 64.00 / 96.00MiB)。 meow_jpeg.h 注明「峰值只与输出尺寸相关」的适用范围:畸形/超大声明尺寸直接拒绝(安全失败)。 验证:test_meow_img_stream ALL PASS(含 ASan 整轮 rc=0 无报告);test_meow_img_budget / test_meow_img_color ALL PASS;run_tests.sh rc=0(zip 106 / cbz 296 / membudget PASS); run_webtoon_tests.sh rc=0;build_sdk.sh 零警告,MIPS32 rel2;体积 meow_jpeg.o 17,212B + meow_png_stream.o 8,456B。峰值不变(修复不增加分配)。 --- sdk/meow_jpeg.c | 23 ++--- sdk/meow_jpeg.h | 4 +- sdk/test_meow_img_stream.c | 184 +++++++++++++++++++++++++++++++++++-- 3 files changed, 192 insertions(+), 19 deletions(-) diff --git a/sdk/meow_jpeg.c b/sdk/meow_jpeg.c index 934056c..0330333 100644 --- a/sdk/meow_jpeg.c +++ b/sdk/meow_jpeg.c @@ -482,6 +482,10 @@ static int mj_sof(mj_img *img, int marker, const unsigned char *d, int len, int for (i = 0; i < n; i++) if (img->c[i].n > base_n) img->c[i].n = base_n; plane_bytes = mj_layout(img); } + /* 硬闸:降到 N=1 仍超上限(畸形/超大声明尺寸,如 23 字节的 SOF 65535² → 亮度面 + * 8192² = 64MiB)直接拒绝 → 返回 0 → 回退 stb(stb 对这类文件是 KB 级安全失败)。 + * 没有这道闸,公开入口 meow_img_load_file_dim(720) 会被一个头文件打爆设备内存。 */ + if (plane_bytes + target_bytes > MJ_PEAK_CAP) return 0; for (i = 0; i < n; i++) { mj_comp *c = &img->c[i]; @@ -596,28 +600,25 @@ rst_done: ; } -/* 区间边界处理(三处扫描循环共用)。返回:0 = 熵段结束(调用方退出扫描); - * 1 = 继续;2 = 刚做过区间重同步(AC 扫描需清 EOBRUN)。 +/* 区间边界处理(三处扫描循环共用)。返回:1 = 继续;2 = 刚做过区间重同步(AC 需清 EOBRUN)。 * * 只在 total == rst(编码器的区间长度)时重同步。**不能**一看到 over&&RSTn 就重同步: - * 熵码位缓冲有最多 9 位前瞻,`over` 会在区间真正结束前 1~2 个 MCU 就被置位, - * 此时 bitbuf 里还压着本区间剩余块的有效比特——提前重同步会把它们丢掉并失步 + * 熵码位缓冲有最多 9 位前瞻,`over` 会在区间/扫描真正结束前 1~2 个 MCU 就被置位, + * 此时 bitbuf 里还压着剩余块的有效比特——提前重同步会把它们丢掉并失步 * (渐进式 DC 扫描每块仅 ~1 bit,前瞻能跨过整个 MCU;这是 rst=2..200 花屏的根因)。 - * 到达 total==rst 时,RSTn 要么已被前瞻吃掉(over/marker=RSTn,清掉即可), - * 要么还在流里(裸扫对齐)——mj_restart 两种都处理。 */ + * 同理,over 且 marker 非 RSTn(扫描结束后的 DHT/EOI 已被前瞻吃掉)时**也不能退出**: + * 扫描循环本来就知道确定的 MCU/块数,把剩余的解完即可(位耗尽后 mj_eb 返回 0 做零填充), + * marker 交给外层主循环处理。曾在此 `return 0` 提前退出,丢掉最后一个 MCU, + * 平面 memset(128) 的灰底直接露出来(低熵/纯白渐进式右下角 3 像素 #808080)。 */ static int mj_boundary(mj_img *img, int *total) { mj_src *s = &img->src; + (void)s; if (img->rst && *total == img->rst) { mj_restart(img); *total = 0; return 2; } - if (s->over) { - /* 前瞻已吃到 RSTn:本区间剩余块的有效比特还在 bitbuf 里,继续解。 - * 只有「非 RST 的 marker」才是熵段真的结束(EOI/下一段的 DHT…)。 */ - return (s->marker >= 0xD0 && s->marker <= 0xD7) ? 1 : 0; - } return 1; } diff --git a/sdk/meow_jpeg.h b/sdk/meow_jpeg.h index 11364d8..5aac22b 100644 --- a/sdk/meow_jpeg.h +++ b/sdk/meow_jpeg.h @@ -6,7 +6,9 @@ * 首个精细化扫描(首个 Ah>0)为止——低频带此时已完整,再往后只是为了更细的量化精度。 * - PNG(meow_png_stream.c):libz inflate 逐行 defilter,按步长丢行丢列直写目标尺寸。 * - BMP(meow_png_stream.c):按行 seek + 行内跳列(同一套「只碰需要的行」思路)。 - * 三者峰值都只与「输出尺寸 + 目标图像」相关,不随源图分辨率线性增长。 + * 三者峰值都只与「输出尺寸 + 目标图像」相关,不随源图分辨率线性增长——**前提是文件头 + * 声明的尺寸在合理范围内**:畸形/超大声明尺寸(例如 23 字节的 SOF 声明 65535×65535)会被 + * 直接拒绝(返回 0 → 回退 stb,安全失败),不会按声明尺寸分配平面。 * * 不支持的特性一律返回 0,由 meow_ui_img.c 回退 stb(行为与 M23 之前完全一致): * 4 分量 CMYK/YCCK、算术编码/无损/分层 JPEG、Adam7 交错 PNG、1/2/4bpp 与 RLE BMP 等。 diff --git a/sdk/test_meow_img_stream.c b/sdk/test_meow_img_stream.c index 2cfabad..4b1534d 100644 --- a/sdk/test_meow_img_stream.c +++ b/sdk/test_meow_img_stream.c @@ -183,6 +183,18 @@ static const char *g_gen_py = "for rb in (1, 2, 3, 4, 5, 8, 16, 64, 200):\n" " big.save(os.path.join(out, 'prog_rst%d.jpg' % rb), 'JPEG', quality=60,\n" " subsampling=2, progressive=True, restart_marker_blocks=rb)\n" +"white = Image.new('RGB', (2000, 3000), (255, 255, 255))\n" +"white.save(os.path.join(out, 'white2000p.jpg'), 'JPEG', quality=60,\n" +" subsampling=2, progressive=True)\n" +"for _q in (50, 70, 80):\n" +" white.save(os.path.join(out, 'white2000p%d.jpg' % _q), 'JPEG', quality=_q,\n" +" subsampling=2, progressive=True)\n" +"mw = Image.new('RGB', (2000, 3000), (255, 255, 255))\n" +"dmw = ImageDraw.Draw(mw)\n" +"dmw.rectangle([60, 60, 260, 180], fill=(0, 0, 0))\n" +"dmw.line([(300, 200), (1900, 200)], fill=(20, 20, 20), width=2)\n" +"mw.save(os.path.join(out, 'mostlywhite2000p.jpg'), 'JPEG', quality=95,\n" +" subsampling=2, progressive=True)\n" "g8 = Image.new('L', (640, 480), 0)\n" "dg = ImageDraw.Draw(g8)\n" "dg.rectangle([100, 100, 400, 300], fill=255)\n" @@ -213,9 +225,12 @@ static int file_exists(const char *p) static void ensure_fixtures(void) { - char *mark = path_join("base2000.jpg"); - int have = file_exists(mark); - free(mark); + /* 标记文件:新增夹具后改这里,旧夹具目录会自动补齐(生成脚本幂等) */ + char *m1 = path_join("base2000.jpg"); + char *m2 = path_join("white2000p.jpg"); + int have = file_exists(m1) && file_exists(m2); + free(m1); + free(m2); if (have) return; mkdir(g_dir, 0755); printf("夹具缺失,生成到 %s(Pillow)\n", g_dir); @@ -470,6 +485,87 @@ static unsigned char *inject_badhuff(const unsigned char *orig, size_t n, return out; } +/* 全图逐像素比较(不是抽样):返回平均绝对差;*maxd = 最大单通道差; + * *big = 差值 > thr 的通道数。尺寸必须一致。 */ +static double full_diff(const meow_img *a, const meow_img *b, int *maxd, long *big, int thr) +{ + int w = meow_img_w(a), h = meow_img_h(a); + const unsigned char *pa = meow_img_pixels(a), *pb = meow_img_pixels(b); + long acc = 0, cnt = 0, nb = 0; + int mx = 0; + if (!pa || !pb || w <= 0 || h <= 0 || meow_img_w(b) != w || meow_img_h(b) != h) { + if (maxd) *maxd = 999; + if (big) *big = -1; + return 1e9; + } + for (long i = 0; i < (long)w * h; i++) + for (int k = 0; k < 3; k++) { + int d = (int)pa[i * 4 + k] - (int)pb[i * 4 + k]; + if (d < 0) d = -d; + if (d > mx) mx = d; + if (d > thr) nb++; + acc += d; + cnt++; + } + if (maxd) *maxd = mx; + if (big) *big = nb; + return cnt ? (double)acc / (double)cnt : 1e9; +} + +/* 全图最小通道值(丢 MCU 会露出 128 灰底,纯白图上立刻可见)。 */ +static int min_channel(const meow_img *im) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *p = meow_img_pixels(im); + int mn = 255; + if (!p || w <= 0 || h <= 0) return -1; + for (long i = 0; i < (long)w * h; i++) + for (int k = 0; k < 3; k++) + if (p[i * 4 + k] < mn) mn = p[i * 4 + k]; + return mn; +} + +/* 矩形区域的最小通道值(丢 MCU 的灰块只出现在最后一块附近,看角落最灵敏)。 */ +static int region_min(const meow_img *im, int x0, int y0, int x1, int y1) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *p = meow_img_pixels(im); + int mn = 255; + if (!p) return -1; + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > w) x1 = w; + if (y1 > h) y1 = h; + for (int y = y0; y < y1; y++) + for (int x = x0; x < x1; x++) + for (int k = 0; k < 3; k++) + if (p[((size_t)y * w + x) * 4 + k] < mn) mn = p[((size_t)y * w + x) * 4 + k]; + return mn; +} + +/* 构造「SOI + SOF0(W×H, ncomp) + EOI」的最小 JPEG(无扫描数据)。 */ +static size_t build_sof_only(unsigned char *buf, int w, int h, int ncomp) +{ + size_t p = 0; + int i; + int seglen = 2 + 6 + 3 * ncomp; + buf[p++] = 0xFF; buf[p++] = 0xD8; /* SOI */ + buf[p++] = 0xFF; buf[p++] = 0xC0; /* SOF0 */ + buf[p++] = (unsigned char)(seglen >> 8); + buf[p++] = (unsigned char)(seglen & 0xFF); + buf[p++] = 8; /* precision */ + buf[p++] = (unsigned char)((h >> 8) & 0xFF); buf[p++] = (unsigned char)(h & 0xFF); + buf[p++] = (unsigned char)((w >> 8) & 0xFF); buf[p++] = (unsigned char)(w & 0xFF); + buf[p++] = (unsigned char)ncomp; + for (i = 0; i < ncomp; i++) { + buf[p++] = (unsigned char)(i + 1); + buf[p++] = (unsigned char)((i == 0 && ncomp > 1) ? 0x22 : 0x11); /* 4:2:0 / 4:4:4 */ + buf[p++] = (unsigned char)(i ? 1 : 0); /* 量化表号 */ + } + buf[p++] = 0xFF; buf[p++] = 0xD9; /* EOI */ + return p; +} + /* ================================================================== */ int main(int argc, char **argv) { @@ -865,13 +961,13 @@ int main(int argc, char **argv) im = meow_img_load_file_dim(p, 720); pk = peak_since(base); if (!(im && nonblank(im) && pk <= PEAK_2500KB)) all_peak = 0; + /* 严格全图逐字节比较(不是网格抽样:64×64=4096 个采样点看不到右下角)。 + * restart 只切分熵段、不改系数流,任何差异都是解码器错误。 */ if (!(im && ref && meow_img_w(im) == meow_img_w(ref) && meow_img_h(im) == meow_img_h(ref) && memcmp(meow_img_pixels(im), meow_img_pixels(ref), (size_t)meow_img_w(ref) * meow_img_h(ref) * 4) == 0)) { - double c2 = 0.0; - double d2 = (im && ref) ? mean_abs_diff(im, ref, 64, &c2) : 1e9; - if (!(d2 < 0.1)) all_equal = 0; /* 允许极个别像素 ±1(编码器分表差异) */ - printf(" RST=%-3d 与无 restart 平均差 %.4f\n", rb[i], d2); + all_equal = 0; + printf(" RST=%-3d 全图 memcmp 失败(与无 restart 不同)\n", rb[i]); } /* 关键正确性:与本文件自己的 stb 整幅解码比对(不同编码器脚本下 restart 文件的 * 系数可能不同,所以拿「无 restart 文件」当基准只能作参考) */ @@ -947,6 +1043,80 @@ int main(int argc, char **argv) meow_img_free(im); } + /* ---- S15 低熵/纯白 progressive(P1-A 回归:最后一个 MCU 不许丢) ---- */ + printf("[S15] 低熵/纯白 progressive 全图逐像素(丢 MCU 会露出 128 灰块)\n"); + { + struct { const char *tag; const char *name; int all_white; } fx[] = { + { "pure white q60", "white2000p.jpg", 1 }, + { "pure white q50", "white2000p50.jpg", 1 }, + { "pure white q70", "white2000p70.jpg", 1 }, + { "pure white q80", "white2000p80.jpg", 1 }, + { "mostly white q95", "mostlywhite2000p.jpg", 0 }, + }; + for (size_t i = 0; i < sizeof fx / sizeof fx[0]; i++) { + char *p = path_join(fx[i].name); + meow_img *im = meow_img_load_file_dim(p, 720); + meow_img *ref = ref_thumb(p, 720); /* stb 整幅 + 同公式降采样 */ + char name[128]; + int mn = min_channel(im); + int cw = meow_img_w(im), ch = meow_img_h(im); + int corner = region_min(im, cw - 24, ch - 24, cw, ch); /* 最后一个 MCU 落点 */ + int maxd = 0; + long big = -1; + double mean = (im && ref) ? full_diff(im, ref, &maxd, &big, 40) : 1e9; + snprintf(name, sizeof name, "%s 解码成功且 480x720", fx[i].tag); + CHECK(im && nonblank(im) && cw == 480 && ch == 720, name); + /* 丢 MCU 会露出平面 memset(128) 的灰底:右下角与全图最小值都会掉到 128 附近。 + * 纯白图全图最小值 ≥250;mostly-white 图有黑色矩形,只看右下角这块。 */ + if (fx[i].all_white) { + snprintf(name, sizeof name, "%s 全图最小通道 = %d(丢块会露 128)", fx[i].tag, mn); + CHECK(mn >= 250, name); + } + snprintf(name, sizeof name, "%s 右下角 24x24 最小通道 = %d(丢块会露 128)", + fx[i].tag, corner); + CHECK(corner >= 250, name); + snprintf(name, sizeof name, "%s 与 stb 全图比较:平均差 %.2f,最大差 %d,>40 的通道 %ld", + fx[i].tag, mean, maxd, big); + CHECK(mean < 24.0 && big >= 0 && big * 200 < (long)720 * 480 * 3, name); + meow_img_free(im); + meow_img_free(ref); + free(p); + } + } + + /* ---- S16 畸形 SOF 声明尺寸(P1-B 回归:不得按声明尺寸分配) ---- */ + printf("[S16] 畸形 SOF 65535x65535(1/3 分量)\n"); + { + unsigned char buf[64]; + static const int nc[2] = { 1, 3 }; + for (size_t i = 0; i < sizeof nc / sizeof nc[0]; i++) { + size_t n = build_sof_only(buf, 65535, 65535, nc[i]); + int tw = 0, th = 0; + unsigned char *px = NULL; + char name[128]; + char *fp = path_join(nc[i] == 1 ? "sof65535_1.jpg" : "sof65535_3.jpg"); + FILE *f; + size_t base, pk; + meow_img *im; + snprintf(name, sizeof name, "SOF 65535² ncomp=%d → 后端拒绝(%zu 字节)", nc[i], n); + CHECK(meow_img_backend_mem(buf, n, 720, &tw, &th, &px) == 0, name); + if (px) free(px); + f = fopen(fp, "wb"); + CHECK(f != NULL, "写临时畸形 SOF 文件"); + if (f) { fwrite(buf, 1, n, f); fclose(f); } + base = peak_reset(); + im = meow_img_load_file_dim(fp, 720); + pk = peak_since(base); + peak_report("_dim(720) SOF 65535²", pk); + snprintf(name, sizeof name, "ncomp=%d 公开入口返回 NULL(安全失败)", nc[i]); + CHECK(im == NULL, name); + snprintf(name, sizeof name, "ncomp=%d 峰值 < 2.5MiB(不按声明尺寸分配)", nc[i]); + CHECK(pk < PEAK_2500KB, name); + meow_img_free(im); + free(fp); + } + } + /* ---- S8 内存入口 ---- */ printf("[S8] 内存入口 meow_img_load_mem_dim\n"); { From 1725af631bf8031ce176e8085f18d3e71a4ee1cf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 11:09:35 +0800 Subject: [PATCH 27/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8E=A5=E7=9C=9F=E7=BC=A9=E8=A7=A3=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20+=20=5Fstep=20=E5=85=A8=E6=A0=BC=E5=BC=8F=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=88M27=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M23 交付了真缩解后端却「修好了用不上」:_step 只接 JPEG,而阅读器的峰值模型 仍是 stb 口径(PNG/BMP 按 W·H·4、JPEG 按缩解后整幅 RGBA),12MP/PNG 页全被判 超预算占位。本次把两处缝焊上: - sdk/meow_ui_img.c:meow_img_load_file_step 的 JPEG-only 限制放开到 PNG/BMP; 新增 meow_img_load_file_step_ex 回报实际路径(backend/stb),日志可核对预判。 - tuku/logic/reader/page_source.[ch]:新增 reader_estimate_peak(),成为峰值模型 + 预算 + MemAvailable 门控的唯一实现(reader 不再另写一份)。 * 后端命中(JPEG SOF0/1/2、≤3 分量、8bit、N=1..7;PNG 非交错/标准压缩/合法色型; BMP DIB≥40/BI_RGB/8·16·24·32bpp)→ 峰值 = 目标图 + 分量平面/行缓冲 + 固定开销, 与源分辨率无关 → 直接放行,不门控; * 只有回退 stb(CMYK/算术编码、Adam7、RLE/1-4bpp、N≥8 小图等)才按 M22 stb 模型 算峰值,超 3MB 或 MemAvailable <6MB 才占位。Adam7 另补 stb 的整幅暂存上界。 - reader:删掉本地 stb 模型/常量,统一调 reader_estimate_peak;日志加 path=backend|stb。 - 测试:6 类典型页(2000×3000 baseline/渐进/restart、1600×2400 PNG/BMP、4MP、12MP) 断言全部 backend + 不占位 + 估算 ≥ 实测且 ≤1.5×(实测 1.03–1.08×);两条路径输出 尺寸逐像素一致;CMYK/Adam7/1bpp 断言走 stb 并按旧门控占位;连翻 30 页无占位、 单页分配峰值 ≤3MB;membudget 链接补 meow_ui_time.c 与 M23 两个解码 TU。 --- sdk/include/meow_ui_image.h | 12 +- sdk/meow_ui_img.c | 51 ++--- tuku/logic/reader/page_source.c | 292 +++++++++++++++++++++++- tuku/logic/reader/page_source.h | 52 +++++ tuku/test/make_mem_samples.py | 76 ++++++- tuku/test/run_tests.sh | 4 +- tuku/test/test_reader_many_pages.c | 6 +- tuku/test/test_reader_membudget.c | 345 +++++++++++++++++++++++++---- tuku/ui/reader/reader.c | 154 +++++-------- 9 files changed, 797 insertions(+), 195 deletions(-) diff --git a/sdk/include/meow_ui_image.h b/sdk/include/meow_ui_image.h index 819257e..805400a 100644 --- a/sdk/include/meow_ui_image.h +++ b/sdk/include/meow_ui_image.h @@ -56,11 +56,17 @@ int meow_img_pick_scale(int longside, int maxdim); /* 追加(M22)显式档位解码:语义与 meow_img_load_file_dim 相同(结果长边 ≤ maxdim, * 超了就再精修一次),只是缩解档位由调用方决定。内存吃紧时可以选更粗的档位——输出更小、 * 峰值更低,代价是细节变软。step <= 1 等价长边约束入口;step > 8 按 8 处理。 - * M23 起:JPEG 先走真缩解后端(step 折算成目标长边 ceil(长边/step),后端按该长边选 DCT 阶), - * 峰值只与输出尺寸相关;不支持的特性回退 stb(step 原样传入)。PNG/BMP 暂仍走 stb —— 阅读器的 - * 单次解码预算模型(M22)还是「整幅物化」口径,待 M27 同步后放开(见 M23 r1 报告)。 */ + * M23 起:先走真缩解后端(step 折算成目标长边 ceil(长边/step),后端按该长边选 DCT 阶), + * 峰值只与输出尺寸相关;不支持的特性回退 stb(step 原样传入)。 + * M27 起 PNG/BMP 也走同一后端入口(阅读器峰值模型已同步为「目标图 + 工作面」口径); + * 断言与实测见 tuku/test/test_reader_membudget.c 的 B7。 */ meow_img *meow_img_load_file_step(const char *path, int maxdim, int step); +/* 追加(M27):与 meow_img_load_file_step 完全相同,额外回报**实际**解码路径: + * used_backend = 1(M23 后端直出目标尺寸)/ 0(回退 stb)。阅读器用它把「门控预判」 + * 与「真实路径」对上日志;宿主测试据此断言后端命中。NULL 时等价 _step。 */ +meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, int *used_backend); + /* 追加(M22)精确重采样到 w×h(最近邻,缩小/放大都支持)。阅读器用它把「缩解档位」的 * 结果一次性落到显示框,使命中页仍是单次原生 blit(不做每帧重采样)。同尺寸返回 NULL * (调用方保留原图);失败返回 NULL。 */ diff --git a/sdk/meow_ui_img.c b/sdk/meow_ui_img.c index 2a8c271..e4d34ad 100644 --- a/sdk/meow_ui_img.c +++ b/sdk/meow_ui_img.c @@ -99,9 +99,12 @@ meow_img *meow_img_load_file_dim(const char *path, int maxdim) } /* 追加(M22):档位由调用方指定。与 meow_img_load_file_dim 同语义(结果长边 ≤ maxdim, - * 超了再精修一次),只是档位不再由 maxdim 反推 —— 内存预算紧时可选更粗的档位。 */ -meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) + * 超了再精修一次),只是档位不再由 maxdim 反推 —— 内存预算紧时可选更粗的档位。 + * M27:真缩解后端优先(step 折算成目标长边 ceil(长边/step),后端按该长边选 DCT 阶, + * 峰值只与输出尺寸相关);后端不支持时回退 stbi_load_scaled(step),语义与 M22 一致。 */ +meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, int *used_backend) { + if (used_backend) *used_backend = 0; if (!path) return NULL; if (step <= 1 && maxdim <= 0) return meow_img_load_file(path); if (step <= 0) step = 1; @@ -110,32 +113,21 @@ meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) if (!stbi_info(path, &w, &h, &c)) return NULL; int longside = w > h ? w : h; if (step == 1 && maxdim <= 0) return meow_img_load_file(path); - /* 追加(M23):真缩解后端优先(本入口只接 JPEG)。step 是「输出别比这个档位更细」的提示: - * 折算成目标长边 ceil(长边/step) 传给后端(后端按该长边自动选 DCT 阶),与下面 - * stbi_load_scaled(step) 的语义一致,峰值只与输出尺寸相关;maxdim<=0 时不精修, - * 结果仍是档位尺寸。后端不支持(CMYK/算术编码/交错…)时回退 stb,行为与 M22 一致。 - * - * PNG/BMP 在本入口暂不接后端:阅读器的单次解码峰值**预算模型**还是 M22 的「整幅物化」 - * 口径,实测峰值骤降会让它的 model≤2×measured 自检失真(详见 M23 r1 报告的 M27 待办)。 - * 待 M27 同步模型后,把这里的 JPEG 判定去掉即可。 */ + /* 追加(M23/M27):真缩解后端优先(JPEG/PNG/BMP 全部格式)。step 是「输出别比这个档位 + * 更细」的提示:折算成目标长边 ceil(长边/step) 传给后端(后端按该长边自动选 DCT 阶), + * 与下面 stbi_load_scaled(step) 的语义一致,峰值只与输出尺寸相关;maxdim<=0 时不精修, + * 结果仍是档位尺寸。后端不支持(CMYK/算术编码/交错/RLE…)时回退 stb,行为与 M22 一致。 + * M27 起阅读器的峰值模型已与后端同口径(page_source 的 reader_estimate_peak), + * 所以 PNG/BMP 也一并接后端——这正是「修好了却用不上」的最后一处缝。 */ { - unsigned char sig[3]; - int is_jpeg = 0; - FILE *fp = fopen(path, "rb"); - if (fp) { - if (fread(sig, 1, 3, fp) == 3 && - sig[0] == 0xFF && sig[1] == 0xD8 && sig[2] == 0xFF) - is_jpeg = 1; - fclose(fp); - } - if (is_jpeg) { - int eff = maxdim > 0 ? maxdim : longside; - int step_cap = (longside + step - 1) / step; - unsigned char *bp = NULL; - int bw = 0, bh = 0; - if (step_cap > 0 && step_cap < eff) eff = step_cap; - if (eff > 0 && meow_img_backend_file(path, eff, &bw, &bh, &bp) && bp) - return img_finish_dim(bp, bw, bh, maxdim > 0 ? maxdim : longside); + int eff = maxdim > 0 ? maxdim : longside; + int step_cap = (longside + step - 1) / step; + unsigned char *bp = NULL; + int bw = 0, bh = 0; + if (step_cap > 0 && step_cap < eff) eff = step_cap; + if (eff > 0 && meow_img_backend_file(path, eff, &bw, &bh, &bp) && bp) { + if (used_backend) *used_backend = 1; + return img_finish_dim(bp, bw, bh, maxdim > 0 ? maxdim : longside); } } unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, step); @@ -143,6 +135,11 @@ meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) return img_finish_dim(p, w, h, maxdim > 0 ? maxdim : longside); } +meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) +{ + return meow_img_load_file_step_ex(path, maxdim, step, NULL); +} + /* 追加(M22):精确重采样到目标尺寸(最近邻,双向)。阅读器把缩解档位的结果一次性 * 落到显示框,之后命中页就是单次原生 blit(M17 的热路径纪律不被粗档位破坏)。 */ static unsigned char *scale_buf(const meow_img *im, int dw, int dh, int cover); diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c index 08f257d..8a0c9e1 100644 --- a/tuku/logic/reader/page_source.c +++ b/tuku/logic/reader/page_source.c @@ -200,10 +200,19 @@ static int probe_png(probe_src *s, mr_img_info *in) if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0; unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20); if (ww == 0 || hh == 0) return 0; - /* IHDR 的位深/颜色类型(紧跟在宽高后)。截断头(stb 也读不下去)按 RGB 8bit 记。 */ + /* IHDR 的位深/颜色类型/压缩/滤波/交错(紧跟宽高之后)。截断头(stb 也读不下去) + * 按 RGB 8bit 记;但后端支持字段保持「未知(0)」→ 保守回退 stb 门控。 */ int depth = 8, ctype = 2; - unsigned char ex[2]; - if (ps_read(s, ex, sizeof ex)) { depth = ex[0]; ctype = ex[1]; } + unsigned char ex[5]; + if (ps_read(s, ex, sizeof ex)) { + depth = ex[0]; + ctype = ex[1]; + in->depth = ex[0]; + in->color = ex[1]; + in->compression = ex[2]; + in->filter = ex[3]; + in->interlace = ex[4]; + } int ch; switch (ctype) { case 0: ch = 1; break; /* 灰度 */ @@ -259,16 +268,20 @@ static int probe_jpeg(probe_src *s, mr_img_info *in) in->codec = MR_IMG_CODEC_JPEG; in->progressive = prog; in->channels = 3; + in->sof = m; /* M27:0xC0/0xC1/0xC2 才可能走后端 */ + in->depth = so[0]; /* 精度(8 才可能走后端) */ /* 未知采样因子时按 4:4:4 估(3·W·H,偏保守):宁可多算峰值,不要低估。 */ in->planes = (long long)ww * hh * 3; unsigned char nc = 0; if (ps_read(s, &nc, 1) && nc >= 1 && nc <= 4 && len >= 8 + 3 * nc) { int hv[4] = { 0, 0, 0, 0 }; + int tq[4] = { 0, 0, 0, 0 }; int hmax = 1, vmax = 1, ok = 1; for (int i = 0; i < nc && ok; i++) { unsigned char comp[3]; /* id / 采样因子 hv / 量化表 */ if (!ps_read(s, comp, sizeof comp)) { ok = 0; break; } hv[i] = comp[1]; + tq[i] = comp[2]; int ch = hv[i] >> 4, cv = hv[i] & 15; if (ch < 1 || ch > 4 || cv < 1 || cv > 4) { ok = 0; break; } if (ch > hmax) hmax = ch; @@ -278,6 +291,13 @@ static int probe_jpeg(probe_src *s, mr_img_info *in) if (hmax % (hv[i] >> 4) != 0 || vmax % (hv[i] & 15) != 0) ok = 0; } if (ok) { + /* M27:给后端支持判定留原始采样因子/量化表号(4 分量已在上面排除)。 */ + in->ncomp = nc; + for (int i = 0; i < nc; i++) { + in->comp_h[i] = (unsigned char)(hv[i] >> 4); + in->comp_v[i] = (unsigned char)(hv[i] & 15); + in->comp_tq[i] = (unsigned char)tq[i]; + } /* 分量面字节 = 与 stb 完全同式的 w2×h2(MCU 对齐 + 采样因子); * 渐进式每个分量还多一张 w2×h2×2 的 short 系数面(M20 实测 +23.5MB 的来源)。 */ long long mcu_x = (ww + 8 * hmax - 1) / (8 * hmax); @@ -310,15 +330,24 @@ static int probe_bmp(probe_src *s, mr_img_info *in) ((unsigned)hdr[24] << 16) | ((unsigned)hdr[25] << 24)); if (hh < 0) hh = -hh; /* 自底向上 BMP */ if (ww <= 0 || hh <= 0) return 0; - unsigned char ex[4]; /* biPlanes(2) / biBitCount(2) */ + unsigned char ex[8]; /* biPlanes(2) / biBitCount(2) / biCompression(4) */ int bpp = 24; - if (ps_read(s, ex, sizeof ex)) bpp = (int)((unsigned)ex[2] | ((unsigned)ex[3] << 8)); + int have_ex = ps_read(s, ex, sizeof ex); + if (have_ex) bpp = (int)((unsigned)ex[2] | ((unsigned)ex[3] << 8)); in->w = ww; in->h = hh; in->codec = MR_IMG_CODEC_BMP; in->channels = bpp >= 8 ? bpp / 8 : 4; /* stb 一律解到 RGBA 目标 */ in->progressive = 0; in->planes = 0; /* stb 直接写 RGBA 目标,不另开源缓冲 */ + /* M27:后端支持判定(DIB≥40 / BI_RGB / 8·16·24·32bpp)。字段读不全就保持未知 → stb。 */ + in->dib = (int)((unsigned)hdr[14] | ((unsigned)hdr[15] << 8) | + ((unsigned)hdr[16] << 16) | ((unsigned)hdr[17] << 24)); + if (have_ex) { + in->depth = bpp; + in->compression = (int)((unsigned)ex[4] | ((unsigned)ex[5] << 8) | + ((unsigned)ex[6] << 16) | ((unsigned)ex[7] << 24)); + } return 1; } @@ -387,6 +416,259 @@ int mr_probe_size(const char *path, int *w, int *h) return 1; } +/* ============================================================ */ +/* 解码峰值模型 + 内存门控(M27:唯一实现) */ +/* ------------------------------------------------------------ + * 阅读器与页源都只调 reader_estimate_peak()——模型口径、预算策略、门控顺序都只在这里 + * 写一份。两个模型: + * 后端(M23):峰值 = 目标图字节 + 后端工作面(JPEG 分量平面 / PNG·BMP 行缓冲)+ 固定开销。 + * 与源图分辨率/压缩体积无关;命中即放行(不做内存门控)。 + * stb(回退):M22 的真实分配模型(planes + 抽取输出 + 精修/落框临时图)。 + * 只有这类页参与 3MB 峰值预算与 MemAvailable < 6MB 门控。 + * ============================================================ */ +#define MR_PEAK_CAP_BYTES (3LL * 1024 * 1024) /* 常态单次解码峰值上限 */ +#define MR_PEAK_HARD_BYTES (12LL * 1024 * 1024) /* MemFree 富余时的绝对上限 */ +#define MR_MEM_FREE_MARGIN_KB (4L * 1024) /* 放宽档:MemFree 需比峰值多这么多 */ +#define MR_MEM_FLOOR_KB (6L * 1024) /* 回退路径:MemAvailable 下限 */ +#define MR_DECODE_SLACK (64LL * 1024) /* 结构体/行缓冲/分配器开销余量 */ +#define MR_BACKEND_SOFT_CAP (2400LL * 1024) /* 与 meow_jpeg.c 的 2.4MB 预算同值 */ +#define MR_BACKEND_HARD_CAP (8LL * 1024 * 1024) /* meow_jpeg.c 的 MJ_PEAK_CAP */ + +long long reader_peak_budget_bytes(long memfree_kb) +{ + long long budget = MR_PEAK_CAP_BYTES; + if (memfree_kb >= 0) { + long long room = (memfree_kb - MR_MEM_FREE_MARGIN_KB) * 1024; + if (room > budget) budget = room; + if (budget > MR_PEAK_HARD_BYTES) budget = MR_PEAK_HARD_BYTES; + } + return budget; +} + +/* 与 meow_img_target_dims / meow_img_downscale 同一公式(纯 C 层不得依赖 SDK)。 */ +static void mr_target_dims(int w, int h, int maxdim, int *tw, int *th) +{ + if (w >= h) { + *tw = maxdim; + *th = (int)((long)h * maxdim / w); + } else { + *th = maxdim; + *tw = (int)((long)w * maxdim / h); + } + if (*tw < 1) *tw = 1; + if (*th < 1) *th = 1; +} + +static long long mr_ceil_div(long long a, long long b) { return (a + b - 1) / b; } + +/* 档位折算:与 meow_img_load_file_step 的 eff 计算逐字一致 + * (eff = min(maxdim, ceil(长边/step)),后端按 eff 选 DCT 阶)。 */ +static int mr_effective_dim(int longside, int step, int dim) +{ + if (step < 1) step = 1; + if (step > 8) step = 8; + int eff = dim; + int cap = (int)mr_ceil_div(longside, step); + if (cap > 0 && cap < eff) eff = cap; + return eff; +} + +/* PNG 位深 × 颜色类型 → 每像素位数(与 meow_png_stream.c 的 png_bits_of 同式)。 */ +static int mr_png_bits(int color, int depth) +{ + switch (color) { + case 0: return depth; + case 2: return depth * 3; + case 3: return depth; + case 4: return depth * 2; + case 6: return depth * 4; + default: return 0; + } +} + +/* 后端支持矩阵(镜像 meow_jpeg.c / meow_png_stream.c 的拒绝条件;字段未知一律不支持)。 + * eff 是后端实际会收到的 maxdim(见 mr_effective_dim)。 */ +static int mr_backend_supported(const mr_img_info *in, int eff) +{ + if (!in || in->w <= 0 || in->h <= 0 || eff <= 0) return 0; + int longside = in->w > in->h ? in->w : in->h; + if (longside <= eff) return 0; /* 小图走旧入口 */ + + switch (in->codec) { + case MR_IMG_CODEC_JPEG: { + if (in->sof != 0xC0 && in->sof != 0xC1 && in->sof != 0xC2) return 0; + if (in->depth != 8) return 0; + if (in->ncomp < 1 || in->ncomp > 3) return 0; + for (int i = 0; i < in->ncomp; i++) { + if (in->comp_h[i] < 1 || in->comp_h[i] > 4) return 0; + if (in->comp_v[i] < 1 || in->comp_v[i] > 4) return 0; + if (in->comp_tq[i] > 3) return 0; + } + int n = (int)mr_ceil_div(8LL * eff, longside); + if (n < 1) n = 1; + return n <= 7; /* N=8 起几乎没有缩解收益 → stb */ + } + case MR_IMG_CODEC_PNG: { + if (in->interlace || in->compression || in->filter) return 0; + if (in->w > 65535 || in->h > 65535) return 0; + if (mr_png_bits(in->color, in->depth) == 0) return 0; + return 1; + } + case MR_IMG_CODEC_BMP: + if (in->dib < 40 || in->dib > 1024) return 0; + if (in->compression != 0) return 0; /* RLE/BITFIELDS → stb */ + return in->depth == 8 || in->depth == 16 || in->depth == 24 || in->depth == 32; + default: + return 0; + } +} + +/* JPEG 分量平面字节(与 meow_jpeg.c 的 mj_layout 同式,n[] 是各分量的 DCT 阶)。 */ +static long long mr_jpeg_plane_bytes(const mr_img_info *in, const int n[3]) +{ + int hmax = 1, vmax = 1; + for (int i = 0; i < in->ncomp; i++) { + if (in->comp_h[i] > hmax) hmax = in->comp_h[i]; + if (in->comp_v[i] > vmax) vmax = in->comp_v[i]; + } + long long mcux = mr_ceil_div(in->w, 8LL * hmax); + long long mcuy = mr_ceil_div(in->h, 8LL * vmax); + long long total = 0; + for (int i = 0; i < in->ncomp; i++) { + long long sw = mr_ceil_div(in->w * (long long)in->comp_h[i], hmax); + long long sh = mr_ceil_div(in->h * (long long)in->comp_v[i], vmax); + long long ow = mr_ceil_div(sw, 8); + long long oh = mr_ceil_div(sh, 8); + long long bx = mcux * in->comp_h[i]; + long long by = mcuy * in->comp_v[i]; + if (bx < ow) bx = ow; + if (by < oh) by = oh; + total += bx * (long long)n[i] * by * (long long)n[i]; + } + return total; +} + +/* 后端峰值:目标图 + 工作面 + 固定开销。JPEG 的两步降档(色度减半、2.4MB 预算、 + * 8MB 硬上限)与 meow_jpeg.c 一致——估算因此能贴着实测,而不是靠放大余量。 */ +static long long mr_backend_peak_bytes(const mr_img_info *in, int eff, int step, + int box_w, int box_h, int *tw, int *th) +{ + int dim = box_w > box_h ? box_w : box_h; + mr_target_dims(in->w, in->h, eff, tw, th); + long long target = (long long)*tw * (*th) * 4; + long long work = 0; + + if (in->codec == MR_IMG_CODEC_JPEG) { + int longside = in->w > in->h ? in->w : in->h; + int base = (int)mr_ceil_div(8LL * eff, longside); + if (base < 1) base = 1; + int n[3] = { base, base, base }; + work = mr_jpeg_plane_bytes(in, n); + /* 4:4:4 色度与亮度等大:亮度平面超过 400KB 时色度降一档(mj_sof 同式)。 */ + if (in->ncomp == 3 && in->comp_h[0] == in->comp_h[1] && in->comp_v[0] == in->comp_v[1] && + work / 3 > 400LL * 1024) { + for (int i = 1; i < 3; i++) if (n[i] > 1) n[i] >>= 1; + work = mr_jpeg_plane_bytes(in, n); + } + /* 平面 + 目标图超 2.4MB:先降色度(亮度保持清晰)。 */ + if (work + target > MR_BACKEND_SOFT_CAP) { + int changed = 0; + for (int i = 1; i < 3; i++) if (n[i] > 1) { n[i] >>= 1; changed = 1; } + if (changed) work = mr_jpeg_plane_bytes(in, n); + } + /* 最后一道防线:异常巨图整体降档(最多 N=1)。 */ + while (work + target > MR_BACKEND_HARD_CAP && base > 1) { + base >>= 1; + for (int i = 0; i < 3; i++) if (n[i] > base) n[i] = base; + work = mr_jpeg_plane_bytes(in, n); + } + } else if (in->codec == MR_IMG_CODEC_PNG) { + long long rowbytes = (in->w * (long long)mr_png_bits(in->color, in->depth) + 7) / 8; + work = 3 * rowbytes + 1; /* line + prev + cur(png_stream 同式) */ + work += 48LL * 1024; /* inflate 状态余量(zlib 窗口等) */ + } else { /* BMP */ + int bpp = in->depth > 0 ? in->depth : 24; + long long rowbytes = (in->w * (long long)bpp + 31) / 32 * 4; + work = rowbytes; /* 单行 seek 缓冲 */ + work += 48LL * 1024; /* 结构体/调色板/stdio 余量 */ + } + + long long peak = target + work + MR_DECODE_SLACK; + + /* 阅读器在「粗档位 + 输出尺寸与显示框不一致」时还会拷一张显示框大小的图; + * 条件与 reader_load_page 逐字对齐,避免把常态误加一倍。 */ + if (step > 1 && dim > 0 && (in->w >= box_w || in->h >= box_h) && + (*tw != box_w || *th != box_h)) { + peak += (long long)box_w * box_h * 4; + } + return peak; +} + +/* stb 回退路径峰值(M22 模型 + M27 Adam7 修正):只有这类页才参与预算门控。 */ +static long long mr_stb_peak_bytes(const mr_img_info *in, int step, int box_w, int box_h) +{ + long long W = in->w, H = in->h; + long long box = (long long)box_w * box_h * 4; + long long dst; + if (in->codec == MR_IMG_CODEC_JPEG) { + if (step < 1) step = 1; + dst = mr_ceil_div(W, step) * mr_ceil_div(H, step) * 4; + } else { + dst = W * H * 4; /* PNG/BMP:stb 无解码期缩放,整幅落地 */ + } + long long peak = in->planes + dst; /* stb 阶段 */ + /* Adam7 交错 PNG:stb 额外整幅暂存各 pass 的 raw 与一张中间 RGBA(实测 1600×2400 + * 45MB,M22 模型只算 26MB)。多算 planes+dst 保证是上界;这类页无论预算如何都超过 + * 12MB 放宽档,结果仍是占位(安全侧)。 */ + if (in->codec == MR_IMG_CODEC_PNG && in->interlace) + peak += in->planes + dst; + long long finished = dst > box ? box : dst; + if (dst + finished > peak) peak = dst + finished; + if (finished + box > peak) peak = finished + box; + return peak + MR_DECODE_SLACK; +} + +void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, + long memfree_kb, long memavail_kb, mr_decode_decision *out) +{ + if (!out) return; + out->path = MR_DECODE_PATH_STB; + out->skip = MR_DECODE_OK; + out->step = 1; + out->tw = out->th = 0; + out->peak_bytes = 0; + out->budget_bytes = reader_peak_budget_bytes(memfree_kb); + if (!in || in->w <= 0 || in->h <= 0 || box_w <= 0 || box_h <= 0) return; + + if (step < 1) step = 1; + if (step > 8) step = 8; + out->step = step; + + int dim = box_w > box_h ? box_w : box_h; + int longside = in->w > in->h ? in->w : in->h; + int eff = mr_effective_dim(longside, step, dim); + + /* 1) 先看后端能否接住:命中 → 直接放行(后端峰值与源尺寸无关,不需要门控)。 */ + if (mr_backend_supported(in, eff)) { + out->path = MR_DECODE_PATH_BACKEND; + out->peak_bytes = mr_backend_peak_bytes(in, eff, step, box_w, box_h, + &out->tw, &out->th); + out->skip = MR_DECODE_OK; + return; + } + + /* 2) 回退 stb:只有这里才做 MemAvailable 与峰值预算门控。 */ + if (longside <= dim) { out->tw = in->w; out->th = in->h; } + else mr_target_dims(in->w, in->h, dim, &out->tw, &out->th); + out->peak_bytes = mr_stb_peak_bytes(in, step, box_w, box_h); + if (memavail_kb >= 0 && memavail_kb < MR_MEM_FLOOR_KB) + out->skip = MR_DECODE_SKIP_LOWMEM; + else if (out->peak_bytes > out->budget_bytes) + out->skip = MR_DECODE_SKIP_PEAK; + else + out->skip = MR_DECODE_OK; +} + /* ============================================================ */ /* mr_book_ops 适配器(目录册) */ /* ============================================================ */ diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h index 18e29ee..fab2893 100644 --- a/tuku/logic/reader/page_source.h +++ b/tuku/logic/reader/page_source.h @@ -107,11 +107,63 @@ typedef struct mr_img_info { int progressive; /* 1=渐进式 JPEG(SOF2/SOF6/SOF10/SOF14) */ int channels; /* 源通道数(JPEG 分量数 / PNG 颜色类型 / BMP 位深/8) */ long long planes; /* 见上:stb 中间面字节(不含最终 RGBA 输出) */ + + /* ---- 追加(M27):真缩解后端(M23)支持判定所需的头字段。 + * 缺省 0 = 未知;判定按「不支持」处理(保守回退 stb 门控),绝不因缺字段误走后端。 ---- */ + int depth; /* JPEG 精度 / PNG 位深 / BMP 位深(bpp) */ + int color; /* PNG 颜色类型(0/2/3/4/6);非 PNG 为 0 */ + int sof; /* JPEG SOF marker(0xC0/0xC1/0xC2 才可能走后端) */ + int interlace; /* PNG 1=Adam7 交错(后端不支持) */ + int compression; /* PNG 压缩法 / BMP BI_*(0 才可能走后端) */ + int filter; /* PNG 滤波法(0 才可能走后端) */ + int dib; /* BMP DIB 头长度(<40 后端不支持) */ + int ncomp; /* JPEG 分量数(1..3;4=CMYK → 后端不支持) */ + unsigned char comp_h[3], comp_v[3], comp_tq[3]; /* JPEG 采样因子/量化表号 */ } mr_img_info; int mr_probe_info(const char *path, mr_img_info *info); int mr_probe_info_mem(const void *buf, size_t len, mr_img_info *info); +/* ---- 追加(M27):解码峰值模型 + 门控的唯一实现 ---- + * 阅读器(ui/reader)与页源(本层)都只调 reader_estimate_peak,不得各写一份模型。 + * 一次调用同时回答三件事: + * path 走后端(MR_DECODE_PATH_BACKEND)还是回退 stb(MR_DECODE_PATH_STB); + * skip 是否占位(MR_DECODE_OK / SKIP_LOWMEM / SKIP_PEAK); + * peak_bytes + tw/th 预估峰值与产出尺寸(日志/测试直接对照实测)。 + * + * 判定顺序(M27 核心设计): + * 1. 头部特征满足 M23 后端支持矩阵(JPEG SOF0/1/2、≤3 分量、8bit、N=1..7; + * PNG 非交错/标准压缩;BMP DIB≥40/BI_RGB/8·16·24·32bpp)→ **走后端**: + * 峰值 = 目标图字节 + 后端工作面 + 固定开销,与源分辨率无关 → 不占位(不做内存门控)。 + * 2. 否则回退 stb:按 M22 的 stb 真实分配模型算峰值,超预算 → SKIP_PEAK; + * MemAvailable < 6MB → SKIP_LOWMEM。这两道门控只作用于回退路径。 + * + * 参数:step 与 meow_img_load_file_step 同义(JPEG 档位提示);box_w/box_h 是阅读器 + * 最终显示框;memfree_kb/memavail_kb 读不到时传 -1。out 必须非 NULL。 + * 估算口径保证 ≥ 实测(--wrap malloc)且不超过实测 1.5×(见 tuku/test 断言)。 */ +#define MR_DECODE_PATH_STB 0 +#define MR_DECODE_PATH_BACKEND 1 + +#define MR_DECODE_OK 0 +#define MR_DECODE_SKIP_LOWMEM 1 +#define MR_DECODE_SKIP_PEAK 2 + +typedef struct mr_decode_decision { + int path; /* MR_DECODE_PATH_* */ + int skip; /* MR_DECODE_OK / SKIP_* */ + int step; /* 实际档位(<=1 按 1) */ + int tw, th; /* 预估最终产出尺寸(含阅读器落框精修) */ + long long peak_bytes; /* 预估峰值(字节,含固定开销) */ + long long budget_bytes; /* 本次预算(字节;SKIP_PEAK 的比较基准) */ +} mr_decode_decision; + +void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, + long memfree_kb, long memavail_kb, mr_decode_decision *out); + +/* 预算策略(reader_estimate_peak 内部用的就是它):常态 3MB;MemFree 富余时放宽到 + * min(12MB, MemFree-4MB);读不到 MemFree(<0)只用常态。暴露给日志/测试,避免复制。 */ +long long reader_peak_budget_bytes(long memfree_kb); + /* ============================================================ */ /* 档案册(M11):.cbz / .zip 当一本书 */ /* ------------------------------------------------------------ diff --git a/tuku/test/make_mem_samples.py b/tuku/test/make_mem_samples.py index 07f4944..928c3b0 100644 --- a/tuku/test/make_mem_samples.py +++ b/tuku/test/make_mem_samples.py @@ -1,38 +1,98 @@ #!/usr/bin/env python3 -"""make_mem_samples.py — 为 test_reader_membudget.c 生成 JPEG 夹具(M22)。 +"""make_mem_samples.py — 为 test_reader_membudget.c 生成解码夹具(M22 / M27)。 用法:python3 make_mem_samples.py <输出目录> -依赖:Pillow。没有 Pillow 时测试自动 SKIP JPEG 断言(BMP/PNG 夹具由 C 直接写)。 +依赖:Pillow。没有 Pillow 时测试自动 SKIP JPEG 相关断言(BMP/PNG 小夹具由 C 直接写)。 -刻意用**结构化图案**而不是噪声:文件极小、解码路径与真实漫画页一致(白底 + 分镜框)。 - page.jpg 1000x1500 baseline(1.5MP,真机崩溃页所在档位) - big4.jpg 2000x2000 baseline(4MP,手机照片档) - prog.jpg 1000x1500 progressive(SOF2:验证渐进识别 + 峰值模型) +M27 追加「6 类典型页」与「回退路径」夹具,用来断言门控模型与真缩解后端同口径: + 典型(应全部走后端、不占位): + base2000.jpg 2000x3000 baseline + prog2000.jpg 2000x3000 progressive + restart2000.jpg 2000x3000 progressive + restart markers + page1600.png 1600x2400 PNG + page1600.bmp 1600x2400 BMP + mp4.jpg 2320x1740(4MP) + mp12.jpg 4000x3000(12MP) + 回退(后端拒绝、按旧 stb 门控占位): + cmyk.jpg 2000x3000 CMYK(4 分量) + adam7.png 1600x2400 Adam7 交错 + mono1.bmp 1600x2400 1bpp + M22 旧断言仍用: + page.jpg / big4.jpg / prog.jpg(1000x1500 / 2000x2000 / 1000x1500 progressive) """ import os +import struct import sys +import zlib from PIL import Image, ImageDraw -def pattern(w, h): - im = Image.new("RGB", (w, h), (248, 247, 244)) +def pattern(w, h, mode="RGB"): + im = Image.new(mode, (w, h), (248, 247, 244) if mode == "RGB" else 200) d = ImageDraw.Draw(im) for k in range(0, min(w, h) - 200, 70): d.rectangle([20 + k, 20 + k, 160 + k, 120 + k], outline=(10, 10, 12), width=3) return im +def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + +def write_adam7(path, img): + """手写 Adam7 交错 PNG(Pillow 不支持写交错)。RGB8。""" + w, h = img.size + px = img.load() + raw = bytearray() + passes = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), + (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)] + for (x0, y0, dx, dy) in passes: + xs = list(range(x0, w, dx)) + ys = list(range(y0, h, dy)) + if not xs or not ys: + continue + for y in ys: + raw.append(0) + for x in xs: + r, g, b = px[x, y][:3] + raw += bytes((r, g, b)) + ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 1) + data = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw))) + chunk(b"IEND", b"")) + with open(path, "wb") as f: + f.write(data) + + def main(): if len(sys.argv) != 2: print(__doc__) return 2 out = sys.argv[1] os.makedirs(out, exist_ok=True) + + # ---- M22 旧断言夹具 ---- pattern(1000, 1500).save(os.path.join(out, "page.jpg"), "JPEG", quality=60, subsampling=2) pattern(2000, 2000).save(os.path.join(out, "big4.jpg"), "JPEG", quality=60, subsampling=2) pattern(1000, 1500).save(os.path.join(out, "prog.jpg"), "JPEG", quality=60, subsampling=2, progressive=True) + + # ---- M27 6 类典型页 ---- + big = pattern(2000, 3000) + big.save(os.path.join(out, "base2000.jpg"), "JPEG", quality=60, subsampling=2) + big.save(os.path.join(out, "prog2000.jpg"), "JPEG", quality=60, subsampling=2, progressive=True) + big.save(os.path.join(out, "restart2000.jpg"), "JPEG", quality=60, subsampling=2, + progressive=True, restart_marker_blocks=8) + pattern(1600, 2400).save(os.path.join(out, "page1600.png"), "PNG") + pattern(1600, 2400).save(os.path.join(out, "page1600.bmp"), "BMP") + pattern(2320, 1740).save(os.path.join(out, "mp4.jpg"), "JPEG", quality=60, subsampling=2) + pattern(4000, 3000).save(os.path.join(out, "mp12.jpg"), "JPEG", quality=60, subsampling=2) + + # ---- M27 回退路径 ---- + big.convert("CMYK").save(os.path.join(out, "cmyk.jpg"), "JPEG", quality=60) + write_adam7(os.path.join(out, "adam7.png"), pattern(1600, 2400)) + pattern(1600, 2400).convert("1").save(os.path.join(out, "mono1.bmp"), "BMP") return 0 diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 89d730d..55aac37 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -159,13 +159,13 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then logic/library.c logic/reader.c logic/reader/page_source.c \ logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ logic/reader/nav.c \ - "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_time.c "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ - "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c "$SDK"/meow_ui_time.c \ + "$SDK"/meow_jpeg.c "$SDK"/meow_png_stream.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_reader_membudget \ diff --git a/tuku/test/test_reader_many_pages.c b/tuku/test/test_reader_many_pages.c index c08536d..463d80c 100644 --- a/tuku/test/test_reader_many_pages.c +++ b/tuku/test/test_reader_many_pages.c @@ -97,7 +97,7 @@ static int write_huge_bmp_header(const char *path) put32(b + 18, 3000); /* width */ put32(b + 22, 3000); /* height */ put16(b + 26, 1); /* planes */ - put16(b + 28, 24); /* bpp */ + put16(b + 28, 1); /* bpp=1:M23 后端不支持 → 回退 stb(M27 门控仍生效) */ FILE *f = fopen(path, "wb"); if (!f) return -1; int ok = fwrite(b, 1, sizeof b, f) == sizeof b; @@ -273,7 +273,9 @@ int main(void) CHECK(bad_ok, "flip across corrupt/huge pages keeps working (page=%d)", mr_reader_ui_page()); CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "huge page skipped without allocating it (%ldKB)", mr_reader_ui_resident_kb()); - /* M22:坏图 = 永久占位(failed);超预算大页 = 预算跳过(skipped,可重试,不是 failed)。 */ + /* M22/M27:坏图 = 永久占位(failed);超预算的**回退路径**大页 = 预算跳过(skipped, + * 可重试,不是 failed)。M27 起后端能接住的 24bpp 大图会直接解出,所以这里用 1bpp + * (后端拒绝 → stb)保证仍在验证门控,而不是「大图一律占位」的旧语义。 */ CHECK(mr_reader_ui_failed_pages() == 1, "garbage page is a permanent placeholder (failed=%d)", mr_reader_ui_failed_pages()); CHECK(mr_reader_ui_skipped_pages() == 1, diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index 6a680a5..e4897bb 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -1,4 +1,4 @@ -/* test_reader_membudget.c — 阅读器解码内存预算回归(M22;需要 SDK 源码树)。 +/* test_reader_membudget.c — 阅读器解码内存预算回归(M22;M27 接真缩解后端;需要 SDK 源码树)。 * * 真机事故(R3 Pro II 480×720 / MemTotal 56872kB):空闲 RSS 6044kB → 翻页 11400kB * → 峰值 15600kB(当时 MemFree 仅 1404kB)→ SIGKILL。M20 审计把账算清:4 槽 RGBA 常驻 @@ -12,10 +12,14 @@ * B4 常驻页图 ≤ 常驻上限(3MB) * B5 内存门控:MemAvailable 不足 → 不解码直接占位(可重试,不是永久失败) * B6 预算跳过可重试:MemFree 充足时同一页能解出来(占位不是永久 failed) - * B7 快翻取消:拖动期间只解落点页,不产生中间页解码 + * B7 M27:6 类典型页(2000×3000 baseline/渐进/restart、1600×2400 PNG/BMP、4MP、12MP) + * 全部走后端、不占位;估算 ≥ 实测且 ≤1.5×;输出尺寸与显示框一致 + * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 + * B8 M27:连翻 30 页(6 类循环)不掉页、不占位、分配峰值 ≤3MB + * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * - * 夹具:BMP/PNG 由本文件用 C 写(不依赖 Pillow);JPEG 由 python3+Pillow 生成, - * 缺 PIL 时跳过 JPEG 相关断言(打印 SKIP)。全部夹具体积极小(结构化图案)。 + * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 + * (test/make_mem_samples.py),缺 PIL 时跳过相关断言(打印 SKIP)。 * * 编译:test/run_tests.sh(与 test_reader_perf 同口径 + --wrap malloc 家族)。 */ @@ -24,6 +28,8 @@ #include "ui/reader/reader.h" #include "meow_ui_internal.h" #include "logic/reader/nav.h" +#include "logic/reader/page_source.h" +#include "stb_image.h" /* stbi_load_scaled:两条路径输出尺寸一致性对照 */ #include #include @@ -156,6 +162,63 @@ static int write_bmp24(const char *path, int w, int h, unsigned seed) return ok ? 0 : -1; } +/* 1bpp BMP(后端不支持 → 回退 stb):用于「回退路径按旧门控占位」的集成断言。 */ +static int write_bmp1(const char *path, int w, int h) +{ + int stride = ((w + 31) / 32) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + unsigned char pal[8] = { 0, 0, 0, 0, 255, 255, 255, 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(14 + 40 + 8 + imgsz)); + put32(fh + 10, (unsigned)(14 + 40 + 8)); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 1); + put32(ih + 20, (unsigned)imgsz); + put32(ih + 32, 2); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih && + fwrite(pal, 1, sizeof pal, f) == sizeof pal; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < stride; x++) + row[x] = (unsigned char)((y / 16 + x) & 1 ? 0x00 : 0xFF); + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int copy_file(const char *src, const char *dst) +{ + FILE *in = fopen(src, "rb"); + if (!in) return -1; + FILE *out = fopen(dst, "wb"); + if (!out) { fclose(in); return -1; } + unsigned char buf[16384]; + size_t n; + int ok = 1; + while ((n = fread(buf, 1, sizeof buf, in)) > 0) + if (fwrite(buf, 1, n, out) != n) { ok = 0; break; } + fclose(in); + if (fclose(out) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int have_file(const char *path) +{ + struct stat st; + return stat(path, &st) == 0 && st.st_size > 0; +} + static unsigned crc32_buf(const unsigned char *p, size_t n) { unsigned c = 0xFFFFFFFFu; @@ -306,10 +369,44 @@ static int gen_jpeg_fixtures(const char *dir) char cmd[512]; snprintf(cmd, sizeof cmd, "python3 test/make_mem_samples.py '%s' >/dev/null 2>&1", dir); if (system(cmd) != 0) return 0; - struct stat st; - char p[MR_PATH_MAX]; - snprintf(p, sizeof p, "%s/page.jpg", dir); - return stat(p, &st) == 0 && st.st_size > 0; + static const char *const need[] = { + "page.jpg", "big4.jpg", "prog.jpg", + "base2000.jpg", "prog2000.jpg", "restart2000.jpg", + "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", + "cmyk.jpg", "adam7.png", "mono1.bmp", + }; + for (size_t i = 0; i < sizeof need / sizeof need[0]; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", dir, need[i]); + if (!have_file(p)) return 0; + } + return 1; +} + +/* 与 reader_load_page 完全同路径:_step_ex → 兜底降采样 → 落框重采样。 + * 返回真实分配峰值(字节),并回报实际路径与最终尺寸。 */ +static size_t measure_page_decode(const char *path, const mr_img_info *in, int step, + int box_w, int box_h, int *used_backend, int *ow, int *oh) +{ + int dim = box_w > box_h ? box_w : box_h; + size_t base = g_live; + peak_reset(); + int used = 0; + meow_img *im = meow_img_load_file_step_ex(path, dim, step, &used); + if (im && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { + meow_img *small = meow_img_downscale(im, dim); + if (small) { meow_img_free(im); im = small; } + } + if (im && in && step > 1 && (in->w >= box_w || in->h >= box_h) && + (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { + meow_img *fit = meow_img_resample(im, box_w, box_h); + if (fit) { meow_img_free(im); im = fit; } + } + if (used_backend) *used_backend = used; + if (ow && oh && im) { *ow = meow_img_w(im); *oh = meow_img_h(im); } + size_t peak = peak_since(base); + if (im) meow_img_free(im); + return peak; } int main(void) @@ -325,29 +422,33 @@ int main(void) char root[MR_PATH_MAX]; snprintf(root, sizeof root, "./mr_membudget_%ld", (long)getpid()); char book[MR_PATH_MAX], big[MR_PATH_MAX], pngbook[MR_PATH_MAX], jpgbook[MR_PATH_MAX]; + char mixed[MR_PATH_MAX], fallback[MR_PATH_MAX]; snprintf(book, sizeof book, "%s/small", root); snprintf(big, sizeof big, "%s/big", root); snprintf(pngbook, sizeof pngbook, "%s/png", root); snprintf(jpgbook, sizeof jpgbook, "%s/jpg", root); + snprintf(mixed, sizeof mixed, "%s/mixed", root); + snprintf(fallback, sizeof fallback, "%s/fallback", root); if (mkdir_p(book) != 0 || mkdir_p(big) != 0 || mkdir_p(pngbook) != 0 || - mkdir_p(jpgbook) != 0) { + mkdir_p(jpgbook) != 0 || mkdir_p(mixed) != 0 || mkdir_p(fallback) != 0) { printf("SKIP: mkdir failed\n"); return 0; } - /* 480x640 BMP ×8:单页解码峰值 ≈ 480*640*4*2 ≈ 2.5MB,能进 3MB 预算(可连翻)。 */ + /* 480x640 BMP ×8:单页解码峰值 ≈ 480*640*4*2 ≈ 2.5MB,能进 3MB 预算(可连翻)。 + * 长边 640 ≤ 解码长边 720 → 后端判定「小图走旧入口」→ stb,正好覆盖回退门控。 */ for (int i = 0; i < 8; i++) { char p[MR_PATH_MAX]; snprintf(p, sizeof p, "%s/p%02d.bmp", book, i); if (write_bmp24(p, 480, 640, (unsigned)i) != 0) { printf("SKIP: bmp write\n"); return 0; } } - /* 1200x1600 BMP:模型峰值 ≈ 7.7MB + 目标 ≈ 9MB —— 3MB 常态预算下必须占位, - * MemFree 富余(放宽到 12MB)时必须能解出来。 */ + /* 1200x1600 1bpp BMP(后端拒绝 → stb):stb 峰值 ≈ 7.7MB + 落框 ≈ 9MB。 + * 3MB 常态预算下必须占位;MemFree 富余(放宽到 12MB)时必须能解出来。 */ { char p[MR_PATH_MAX]; snprintf(p, sizeof p, "%s/huge.bmp", big); - if (write_bmp24(p, 1200, 1600, 3) != 0) { printf("SKIP: bmp write\n"); return 0; } + if (write_bmp1(p, 1200, 1600) != 0) { printf("SKIP: bmp write\n"); return 0; } } - /* 600x960 PNG(RGB):走「源缓冲 + RGBA 转换」路径(stb 不能解码期缩解)。 */ + /* 600x960 PNG(RGB):长边 960 > 720 → 走后端;覆盖 PNG 逐行路径。 */ for (int i = 0; i < 3; i++) { char p[MR_PATH_MAX]; snprintf(p, sizeof p, "%s/q%d.png", pngbook, i); @@ -355,6 +456,31 @@ int main(void) } int have_jpeg = gen_jpeg_fixtures(jpgbook); + /* 连翻 30 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 + * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 */ + static const char *const g_mix[] = { + "base2000.jpg", "prog2000.jpg", "page1600.png", + "page1600.bmp", "mp4.jpg", "mp12.jpg", + }; + const int mix_n = (int)(sizeof g_mix / sizeof g_mix[0]); + int mixed_pages = 0; + if (have_jpeg) { + for (int i = 0; i < 30; i++) { + char src[MR_PATH_MAX], dst[MR_PATH_MAX]; + snprintf(src, sizeof src, "%s/%s", jpgbook, g_mix[i % mix_n]); + snprintf(dst, sizeof dst, "%s/p%02d_%s", mixed, i, g_mix[i % mix_n]); + if (copy_file(src, dst) == 0) mixed_pages++; + } + } + /* 回退路径集成:CMYK(4 分量)必须占位且不崩。 */ + int have_fallback = 0; + if (have_jpeg) { + char src[MR_PATH_MAX], dst[MR_PATH_MAX]; + snprintf(src, sizeof src, "%s/cmyk.jpg", jpgbook); + snprintf(dst, sizeof dst, "%s/cmyk.jpg", fallback); + if (copy_file(src, dst) == 0) have_fallback = 1; + } + meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } @@ -486,47 +612,168 @@ int main(void) pump_ms(200); } - /* ---- B7 JPEG:模型不低估 + 3MB 阈值下「哪些 JPEG 会变占位」---- */ + /* ---- B7 M27:6 类典型页 —— 全部走后端、不占位、模型 ≥ 实测且 ≤1.5× ---- */ if (have_jpeg) { - char p[MR_PATH_MAX]; - snprintf(p, sizeof p, "%s/page.jpg", jpgbook); - long long model = mr_reader_ui_decode_peak_kb(p, 2, 480, 720); - size_t b0 = g_live; - peak_reset(); - meow_img *im = meow_img_load_file_step(p, 720, 2); - if (im && (meow_img_w(im) != 480 || meow_img_h(im) != 720)) { - meow_img *fit = meow_img_resample(im, 480, 720); - if (fit) { meow_img_free(im); im = fit; } + static const char *const typical[] = { + "base2000.jpg", "prog2000.jpg", "restart2000.jpg", + "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", + }; + printf(" [B7] 典型页(fit-width 480,框按源宽高比):\n"); + printf(" %-16s %-8s %10s %10s %6s\n", "page", "path", "est(kB)", "measured", "ratio"); + for (size_t i = 0; i < sizeof typical / sizeof typical[0]; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", jpgbook, typical[i]); + mr_img_info in; + if (!mr_probe_info(p, &in)) { CHECK(0, "probe %s", typical[i]); continue; } + int box_w = 480; + int box_h = (int)((long)in.h * box_w / in.w); + int dim = box_w > box_h ? box_w : box_h; + int step = in.codec == MR_IMG_CODEC_JPEG + ? meow_img_pick_scale(in.w > in.h ? in.w : in.h, dim) : 1; + mr_decode_decision dec; + reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); + int used = 0, ow = 0, oh = 0; + size_t measured = measure_page_decode(p, &in, dec.step, box_w, box_h, + &used, &ow, &oh) + 1; + printf(" %-16s %-8s %10lld %10zu %6.2f\n", typical[i], + dec.path == MR_DECODE_PATH_BACKEND ? "backend" : "stb", + dec.peak_bytes / 1024, measured / 1024, + (double)dec.peak_bytes / (double)measured); + CHECK(dec.path == MR_DECODE_PATH_BACKEND, "%s 走后端", typical[i]); + CHECK(dec.skip == MR_DECODE_OK, "%s 不占位", typical[i]); + CHECK(used == 1, "%s 实际路径=backend", typical[i]); + CHECK(dec.peak_bytes <= 2560LL * 1024, "%s 估算 ≤2560kB (%lldkB)", + typical[i], dec.peak_bytes / 1024); + CHECK(dec.peak_bytes >= (long long)measured, "%s 模型 ≥ 实测 (%lldkB < %zukB)", + typical[i], dec.peak_bytes / 1024, measured / 1024); + CHECK(dec.peak_bytes <= (long long)measured * 3 / 2, "%s 模型 ≤1.5× 实测", + typical[i]); + CHECK(ow == box_w && oh == box_h, "%s 输出 %dx%d == 显示框 %dx%d", + typical[i], ow, oh, box_w, box_h); + } + /* 输出尺寸口径:后端直出 == 「stbi_load_scaled(step) + 落框精修」旧路径 + * (典型页必须逐像素同尺寸;源尺寸不是档位整数倍时 M23 已记录 1px 舍入口径差)。 */ + for (size_t i = 0; i < sizeof typical / sizeof typical[0]; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", jpgbook, typical[i]); + mr_img_info in; + if (!mr_probe_info(p, &in)) continue; + int box_w = 480; + int box_h = (int)((long)in.h * box_w / in.w); + int dim = box_w > box_h ? box_w : box_h; + int step = in.codec == MR_IMG_CODEC_JPEG + ? meow_img_pick_scale(in.w > in.h ? in.w : in.h, dim) : 1; + int used = 0, ow = 0, oh = 0; + measure_page_decode(p, &in, step, box_w, box_h, &used, &ow, &oh); + /* 旧路径(与 meow_img_load_file_step 的 stb 分支同式):先 stbi 缩解,再精修。 */ + int sw = 0, sh = 0, sc = 0, ew = 0, eh = 0; + unsigned char *raw = stbi_load_scaled(p, &sw, &sh, &sc, 4, step); + if (raw) { + if (sw <= dim && sh <= dim) { ew = sw; eh = sh; } + else if (sw >= sh) { + ew = dim; + eh = (int)((long)sh * dim / sw); + if (eh < 1) eh = 1; + } else { + eh = dim; + ew = (int)((long)sw * dim / sh); + if (ew < 1) ew = 1; + } + } + if (raw) stbi_image_free(raw); + CHECK(used == 1 && ow == ew && oh == eh, + "%s 两条路径输出尺寸一致(后端 %dx%d / stb %dx%d)", + typical[i], ow, oh, ew, eh); + } + /* 回退路径:后端拒绝的 3 类必须走 stb,且按旧门控占位(不崩、不误放行)。 */ + static const char *const fallback[] = { "cmyk.jpg", "adam7.png", "mono1.bmp" }; + printf(" [B7b] 回退路径(必须 stb + 占位):\n"); + for (size_t i = 0; i < sizeof fallback / sizeof fallback[0]; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", jpgbook, fallback[i]); + mr_img_info in; + if (!mr_probe_info(p, &in)) { CHECK(0, "probe %s", fallback[i]); continue; } + int box_w = 480; + int box_h = (int)((long)in.h * box_w / in.w); + int dim = box_w > box_h ? box_w : box_h; + int step = in.codec == MR_IMG_CODEC_JPEG + ? meow_img_pick_scale(in.w > in.h ? in.w : in.h, dim) : 1; + mr_decode_decision dec; + reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); + int used = 1, ow = 0, oh = 0; + size_t measured = measure_page_decode(p, &in, dec.step, box_w, box_h, + &used, &ow, &oh) + 1; + printf(" %-16s %-8s %10lld %10zu %6.2f\n", fallback[i], + dec.path == MR_DECODE_PATH_BACKEND ? "backend" : "stb", + dec.peak_bytes / 1024, measured / 1024, + (double)dec.peak_bytes / (double)measured); + CHECK(dec.path == MR_DECODE_PATH_STB, "%s 回退 stb", fallback[i]); + CHECK(dec.skip == MR_DECODE_SKIP_PEAK, "%s 按旧门控占位", fallback[i]); + CHECK(used == 0, "%s 实际路径=stb", fallback[i]); + CHECK(dec.peak_bytes >= (long long)measured, "%s stb 模型 ≥ 实测", fallback[i]); + CHECK(dec.peak_bytes <= (long long)measured * 3 / 2, "%s stb 模型 ≤1.5× 实测", + fallback[i]); + if (ow > 0 && oh > 0) {} /* 尺寸仅记录 */ } - size_t measured = peak_since(b0) / 1024 + 1; - int got = im ? 1 : 0; - if (im) meow_img_free(im); - INFO("jpeg 1000x1500 step=2: model=%lldkB measured=%zukB decoded=%d", model, measured, got); - CHECK(got, "jpeg fixture decodes"); - CHECK(model >= (long long)measured, "jpeg model upper bound (%lldkB < %zukB)", model, measured); - CHECK(model <= (long long)measured * 2, "jpeg model not pessimistic (%lldkB vs %zukB)", - model, measured); - /* 1.5MP(真机崩溃页档位)在 3MB 预算内必须可解:某档 step ≤ cap */ - long long cap = mr_reader_ui_peak_cap_kb(); - long long best = mr_reader_ui_decode_peak_kb(p, 8, 480, 720); - INFO("jpeg 1000x1500 step=8 model=%lldkB cap=%lldkB", best, cap); - CHECK(best <= cap, "1.5MP jpeg fits the 3MB cap at some step (%lldkB > %lldkB)", best, cap); - /* 4MP(手机照片档)在 3MB 预算下不可解 → 必须占位(诚实代价,见报告) */ - snprintf(p, sizeof p, "%s/big4.jpg", jpgbook); - long long big = mr_reader_ui_decode_peak_kb(p, 8, 480, 480); - INFO("jpeg 2000x2000 step=8 model=%lldkB (cap=%lldkB -> %s)", - big, cap, big <= cap ? "decodable" : "placeholder"); - CHECK(big > cap, "4MP jpeg exceeds the 3MB cap even at step=8 (as documented)"); - /* 渐进式:识别 + 峰值模型包含 short 系数面(同样尺寸必须比 baseline 高) */ - snprintf(p, sizeof p, "%s/prog.jpg", jpgbook); - long long prog = mr_reader_ui_decode_peak_kb(p, 2, 480, 720); - INFO("jpeg 1000x1500 progressive step=2 model=%lldkB", prog); - CHECK(prog > model, "progressive jpeg modeled higher than baseline (%lldkB vs %lldkB)", - prog, model); } else { printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } + /* ---- B8 M27:连翻 30 页(6 类典型页循环)——不掉页、不占位、峰值 ≤3MB ---- */ + if (have_jpeg && mixed_pages == 30) { + mr_reader_ui_test_set_mem(4096, 16000); + mr_reader_open_dir(mixed); + pump_ms(1200); + CHECK(mr_reader_ui_count() == 30, "mixed book pages=%d", mr_reader_ui_count()); + int w = meow_ui_width(); + int advanced = 0, skipped_max = 0; + size_t live_steady = g_live; + size_t peak_steady = g_peak; + int d0 = mr_reader_ui_decode_count(); + size_t peak_max = 0; + for (int i = 0; i < 30; i++) { + int before = mr_reader_ui_page(); + size_t base = g_live; + peak_reset(); /* 每页单独量「相对当前存活」的瞬时增量 */ + tap(i % 2 == 0 ? 40 : w - 40, 300); + pump_ms(220); + size_t step_peak = g_peak > base ? g_peak - base : 0; + if (step_peak > peak_max) peak_max = step_peak; + if (mr_reader_ui_page() != before) advanced++; + if (mr_reader_ui_skipped_pages() > skipped_max) + skipped_max = mr_reader_ui_skipped_pages(); + } + size_t transient = g_peak > peak_steady ? g_peak - peak_steady : 0; + INFO("30 mixed flips: advanced=%d skipped=%d decodes=%d steady=%zuKB " + "max_step_peak=%zuKB cumulative=%zuKB", + advanced, skipped_max, mr_reader_ui_decode_count() - d0, + live_steady / 1024, peak_max / 1024, transient / 1024); + CHECK(advanced == 30, "30 页全部翻到 (%d/30)", advanced); + CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); + CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", + mr_reader_ui_failed_pages()); + CHECK(peak_max <= 3 * 1024 * 1024, + "30 页连翻单页分配峰值 ≤3MB (%zuKB)", peak_max / 1024); + meow_view_page_pop(); + pump_ms(200); + } + + /* ---- B9 M27:回退路径集成 —— CMYK 页占位(可重试)且不崩 ---- */ + if (have_fallback) { + mr_reader_ui_test_set_mem(4096, 16000); + mr_reader_open_dir(fallback); + pump_ms(900); + CHECK(mr_reader_ui_count() == 1, "fallback book pages=%d", mr_reader_ui_count()); + CHECK(mr_reader_ui_skipped_pages() == 1, "CMYK 页按旧门控占位 (skipped=%d)", + mr_reader_ui_skipped_pages()); + CHECK(mr_reader_ui_failed_pages() == 0, "CMYK 页不是永久失败 (%d)", + mr_reader_ui_failed_pages()); + CHECK(mr_reader_ui_resident_kb() == 0, "CMYK 页未驻留 (resident=%ldkB)", + mr_reader_ui_resident_kb()); + meow_view_page_pop(); + pump_ms(200); + } + meow_view_page_pop(); meow_view_free(reader); meow_view_free(lib); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index bc23033..24b528a 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -15,11 +15,14 @@ * 宽恒为视口宽),不是屏长边,所以翻页/滚动热路径只有原生 blit:不做 meow_ui_img_scaled * 的每帧重采样,也不再每帧 malloc 1MB 级临时缓冲。几何变化(fit/缩放/切模式)时按新几何 * 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 - * 内存(M17 起;M22 按真机实测重定):单页显示缓存 ≤3MB、整窗常驻 ≤3MB(3 槽,超限淘汰 - * 最远页),单次解码真实峰值常态 ≤3MB —— 超出时 JPEG 先降缩解档位、PNG/BMP 直接占位 - * (占位可重试,不是永久失败)。峰值模型见 decode_peak_bytes():它按 stb 的真实分配算 - * (JPEG 分量面、渐进的 short 系数面、PNG 的源缓冲 + RGBA 转换副本),不再是「把 stb 当 - * 缩解库」的低估式。余量确实充足(MemFree 富余)时允许放宽到 MR_DECODE_PEAK_HARD。 + * 内存(M17 起;M22 按真机实测重定;M27 接真缩解后端):单页显示缓存 ≤3MB、整窗常驻 + * ≤3MB(3 槽,超限淘汰最远页)。解码路径与峰值预算由 page_source 的 reader_estimate_peak() + * 统一判定(唯一实现,本文件不再另写模型): + * - 真缩解后端(M23)能接住的页:峰值只与目标尺寸相关(≈1.3–2.5MB,与源分辨率无关), + * 直接放行,不做内存门控; + * - 后端拒绝的页(CMYK/算术编码 JPEG、Adam7 PNG、RLE/1–4bpp BMP、N≥8 小图…)回退 stb: + * 按 stb 真实分配模型算峰值,超 3MB 占位 / MemAvailable <6MB 占位。 + * 占位可重试,不是永久失败。余量确实充足(MemFree 富余)时允许放宽到 12MB。 * 页数增长不会线性吃内存(常驻只与解码窗大小有关)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * @@ -74,15 +77,10 @@ #define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ #define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ #define MR_TOAST_MS 1400 -/* ---- 内存预算(M22,按 R3 Pro II 实测校准:MemTotal 56872kB、空闲 RSS 6044kB、 - * 杀点 RSS≈15.6MB / 当时 MemFree 仅 1.4MB;M20 宿主实测单页解码尖峰 3.4–35MB)---- */ -#define MR_DECODE_PEAK_CAP (3L * 1024 * 1024) /* 常态单次解码峰值上限(真实模型;3MB 硬阈值) */ -#define MR_DECODE_PEAK_HARD (12L * 1024 * 1024) /* 余量充足时的绝对峰值上限(不再超老 12MB 档) */ -#define MR_MEM_FREE_MARGIN_KB (4L * 1024) /* 放宽档:MemFree 必须比峰值多出这么多 */ -#define MR_MEM_FLOOR_KB (6L * 1024) /* 解码前门控:MemAvailable 低于此值不解码 */ +/* ---- 内存预算(M22 校准;M27 起模型与门控只在 page_source.c 的 reader_estimate_peak 里)---- + * 真机参考:MemTotal 56872kB、空闲 RSS 6044kB、杀点 RSS≈15.6MB / MemFree 仅 1.4MB。 */ #define MR_PREFETCH_MIN_KB (12L * 1024) /* 预取邻居要求的最低 MemAvailable */ #define MR_SKIP_RETRY_MS 1500 /* 被预算跳过的页:焦点页冷却这么久后自动重试 */ -#define MR_DECODE_SLACK (64L * 1024) /* 峰值模型余量:结构体/行缓冲/分配器开销(实测 5–6KB) */ #define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */ #define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */ #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ @@ -420,60 +418,17 @@ static void plan_box_size(int want_w, int want_h, int *bw, int *bh) *bh = h; } -/* 真实解码峰值模型(M22)。上一版把 stb 当「缩解库」估 (W/s)(H/s)·4,对 JPEG 低估 3.5–8 倍 - * (M20 宿主实测:2000×3000 baseline +10.1MB、渐进 +23.5MB、1200×1600 PNG +12.9MB)。 - * 这里按 stb 的真实分配逐项相加(与 stb_image.h 的分配点对齐): - * 1) stb 阶段:中间面 planes + 解码输出缓冲 dst(两者并存到解压结束) - * JPEG:planes = 分量面(+渐进系数面),dst = (W/s)(H/s)·4(s>1 时只物化抽取输出) - * PNG :planes = 源像素缓冲,dst = W·H·4(ch==4 时同一缓冲,planes 记 0) - * BMP :planes = 0,dst = W·H·4(stb 直接写 RGBA 目标) - * 2) 精修阶段:dst 超长边上限时再落一张 box 大小的图(dst + finished 并存) - * 3) 落框阶段:档位偏粗时把 finished 重采样到显示框(finished + box 并存) - * 峰值 = 三段的最大值。返回字节数(不是 kB),参数非法返回 0。 */ -static long long decode_peak_bytes(const mr_img_info *in, int step, int box_w, int box_h) -{ - if (!in || in->w <= 0 || in->h <= 0 || box_w <= 0 || box_h <= 0) return 0; - long long W = in->w, H = in->h; - long long box = (long long)box_w * box_h * 4; - long long dst; - if (in->codec == MR_IMG_CODEC_JPEG) { - if (step < 1) step = 1; - dst = ((W + step - 1) / step) * ((H + step - 1) / step) * 4; - } else { - dst = W * H * 4; /* PNG/BMP:无解码期缩放,整幅落地 */ - } - long long peak = in->planes + dst; /* stb 阶段 */ - long long finished = dst > box ? box : dst; /* 精修/落框后的最终图 */ - if (dst + finished > peak) peak = dst + finished; - if (finished + box > peak) peak = finished + box; - return peak + MR_DECODE_SLACK; /* 结构体/行缓冲/分配器开销的实测余量 */ -} - -/* 本次解码的峰值预算: - * - 常态 = MR_DECODE_PEAK_CAP(3MB 硬阈值,覆盖基线 6044 + 常驻 3MB + 峰值 3MB ≤ 13.5MB); - * - 只有 MemFree 真的富余时才放宽(真机杀点由 MemFree 见底触发,不是固定 RSS), - * 放宽上限 = min(MR_DECODE_PEAK_HARD, MemFree - MR_MEM_FREE_MARGIN_KB); - * - 读不到 MemFree → 只用常态预算(不猜)。 */ -static long long decode_peak_budget(long memfree_kb) -{ - long long budget = MR_DECODE_PEAK_CAP; - if (memfree_kb >= 0) { - long long room = (memfree_kb - MR_MEM_FREE_MARGIN_KB) * 1024; - if (room > budget) budget = room; - if (budget > MR_DECODE_PEAK_HARD) budget = MR_DECODE_PEAK_HARD; - } - return budget; -} - -/* 固定字段的解码日志(任务 5):page/src/peak/memfree/memavail 之后是结论, - * 便于 `grep 'reader decode'` 后按列看曲线。 */ +/* 固定字段的解码日志:page/src/peak/path 之后是结论,便于 `grep 'reader decode'` + * 后按列看曲线。peak = reader_estimate_peak 的预估峰值(字节/kB);path = 实际解码路径 + * (backend = M23 真缩解后端,stb = 回退旧路径)。 */ static void log_decode(int page, int src_w, int src_h, long long peak_bytes, long memfree, long memavail, const char *verdict, int step, - unsigned long long ms) + int backend, unsigned long long ms) { meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s step=%d ms=%llu", - page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, step, ms); + "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu", + page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, + backend ? "backend" : "stb", step, ms); } static meow_img *reader_load_page(const char *path, void *ud, int maxdim) @@ -501,50 +456,47 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) long mfree = mem_free_kb(); long mavail = mem_avail_kb(); - /* 1) 门控(任务 1):MemAvailable 不足 → 不解码、不分配,直接占位(可重试)。 - * 阈值 = MR_MEM_FLOOR_KB。读不到(-1)按「可解码」放行,不因探测失败卡死阅读。 */ - if (mavail >= 0 && mavail < MR_MEM_FLOOR_KB) { - g_decode_skip = MR_SKIP_LOWMEM; - log_decode(page, 0, 0, 0, mfree, mavail, "skip=lowmem", 1, - (unsigned long long)(meow_ui_time_ms() - t0)); - return NULL; - } - - /* 2) 头部探测(零像素内存)→ 真实峰值模型 → 选缩解档位。 */ + /* 1) 头部探测(零像素内存),再交给 page_source 的唯一模型定径: + * reader_estimate_peak 同时给出「走后端还是 stb」与「是否占位」。后端能接住的页 + * (M23,峰值与源尺寸无关)直接放行;只有回退 stb 的页才参与峰值预算/MemAvailable + * 门控。模型不再在本文件另写一份。 */ mr_img_info in; int have = mr_probe_info(path, &in); - int step = 1; - long long peak = 0; + mr_decode_decision dec; + memset(&dec, 0, sizeof dec); + dec.step = 1; if (have) { - long long budget = decode_peak_budget(mfree); + int step = 1; if (in.codec == MR_IMG_CODEC_JPEG) { - /* 从「不欠采样」的档位起步,逐步加粗直到峰值进预算。 - * 分量面与档位无关,所以大页最终仍会无档可用 → 占位(诚实代价)。 */ int l0 = in.w > in.h ? in.w : in.h; step = meow_img_pick_scale(l0, dim); - for (;;) { - peak = decode_peak_bytes(&in, step, box_w, box_h); - if (peak <= budget) break; - if (step >= 8) { step = 8; break; } - step *= 2; - } - } else { - step = 1; - peak = decode_peak_bytes(&in, step, box_w, box_h); } - if (peak > budget) { - g_decode_skip = MR_SKIP_PEAK; - log_decode(page, in.w, in.h, peak, mfree, mavail, "skip=peak", step, + for (;;) { + reader_estimate_peak(&in, step, box_w, box_h, mfree, mavail, &dec); + /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ + if (dec.path == MR_DECODE_PATH_BACKEND || dec.skip == MR_DECODE_OK || + step >= 8 || in.codec != MR_IMG_CODEC_JPEG) + break; + step *= 2; + } + if (dec.skip != MR_DECODE_OK) { + g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; + log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, + dec.skip == MR_DECODE_SKIP_LOWMEM ? "skip=lowmem" : "skip=peak", + dec.step, dec.path == MR_DECODE_PATH_BACKEND, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } } - /* 3) 解码(档位已定)。失败与「跳过」必须分开:NULL + skip==0 才是坏图。 */ - meow_img *im = have ? meow_img_load_file_step(path, dim, step) + /* 2) 解码(路径/档位已定)。失败与「跳过」必须分开:NULL + skip==0 才是坏图。 + * step_ex 回报实际路径,日志据此区分 backend / stb(决策与实际必须一致)。 */ + int used_backend = 0; + meow_img *im = have ? meow_img_load_file_step_ex(path, dim, dec.step, &used_backend) : meow_img_load_file_dim(path, dim); if (!im) { - log_decode(page, have ? in.w : 0, have ? in.h : 0, peak, mfree, mavail, "fail", step, + log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail", + dec.step, used_backend, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -553,16 +505,17 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } } - /* 4) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit, + /* 3) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit, * 不会把内存换来的降级变成每帧重采样(M17 的热路径纪律)。 * 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节, * 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */ - if (have && step > 1 && (in.w >= box_w || in.h >= box_h) && + if (have && dec.step > 1 && (in.w >= box_w || in.h >= box_h) && (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { meow_img *fit = meow_img_resample(im, box_w, box_h); if (fit) { meow_img_free(im); im = fit; } } - log_decode(page, have ? in.w : 0, have ? in.h : 0, peak, mfree, mavail, "ok", step, + log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "ok", + dec.step, used_backend, (unsigned long long)(meow_ui_time_ms() - t0)); return im; } @@ -2552,15 +2505,18 @@ void mr_reader_ui_test_set_mem(long free_kb, long avail_kb) g_mem_free_override = free_kb; g_mem_avail_override = avail_kb; } -/* 峰值模型自检(只读):用真代码的模型算某个文件的预估峰值,供宿主测试与实测对照。 */ +/* 峰值模型自检(只读):用真代码的模型算某个文件的预估峰值,供宿主测试与实测对照。 + * 走 page_source 的唯一实现(memfree/mavail 传 -1 = 只算模型,不注入门控读数)。 */ long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int box_h) { mr_img_info in; + mr_decode_decision dec; if (!path || !mr_probe_info(path, &in)) return 0; - return decode_peak_bytes(&in, step, box_w, box_h) / 1024; + reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); + return dec.peak_bytes / 1024; } -long mr_reader_ui_peak_cap_kb(void) { return (long)(MR_DECODE_PEAK_CAP / 1024); } -long mr_reader_ui_peak_budget_kb(void) { return (long)(decode_peak_budget(mem_free_kb()) / 1024); } +long mr_reader_ui_peak_cap_kb(void) { return (long)(reader_peak_budget_bytes(-1) / 1024); } +long mr_reader_ui_peak_budget_kb(void) { return (long)(reader_peak_budget_bytes(mem_free_kb()) / 1024); } long mr_reader_ui_resident_cap_kb(void) { return (long)(MR_RESIDENT_CAP / 1024); } int mr_reader_ui_blit_draws(void) { return g_blit_draws; } int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } From 11e09af7a434e49b2691a0708a829d9453861f03 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 11:38:50 +0800 Subject: [PATCH 28/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=20M27=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P1=20=E9=97=A8=E6=8E=A7=E6=97=81=E8=B7=AF?= =?UTF-8?q?=20+=20=E4=B8=89=E4=B8=AA=20P2=EF=BC=88r1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1:判定为 backend 的页不再无条件放行 + 后端中途失败不再静默回退 stb。 - meow_img_load_file_step_ex 增加 allow_stb_fallback 参数:0 = 只试后端, 失败返回 NULL(坏图占位),绝不回落 stb。阅读器按 dec.path 传入。 - 旧行为实测:截断 12MP JPEG 18,403kB、3000×3000 24bpp 头-only BMP 36,056kB、 非法位深 PNG 16,409kB(stb 都"成功"出图)。修复后: 截断 JPEG 275kB→NULL、头-only BMP 908kB→NULL、非法位深 PNG 门控占位(0 分配)。 P2-1:PNG 支持矩阵补 png_depth_ok 同式(color 2/4/6 仅 8/16;color 3 无 16; color 0 允许 1/2/4/8/16),不再出现「矩阵说支持、后端拒绝」。 P2-2:后端命中页也保留两道安全阀:MemAvailable<6MB 占位、估算 >12MB 硬上限占位。 P2-3:B8 指标改为「窗口稳定后只 reset 一次」,量整个 30 翻窗口内相对稳态基线的 最大存活增量(淘汰不再掩盖峰值),并打印稳态/驻留/增量。 P2-4:page_source.h 的估算精度口径限定为「后端命中/典型页」。 回归: - B7c 新增截断 12MP JPEG / 24bpp 头-only BMP / 非法位深 PNG,断言 backend-only NULL 且峰值 ≤3MB、或 stb 门控占位;B7 典型页与 B7b 回退矩阵保持全绿。 - test_reader_many_pages 恢复 24bpp 头-only 夹具(1bpp 保留):24bpp 判 failed、 1bpp 判 skipped;断言 failed=2 / skipped=1。 - run_tests.sh / run_webtoon_tests.sh EXIT=0;sdk/test_meow_img_stream ALL PASS; 交叉构建(SDK + tuku)零警告。 --- sdk/include/meow_ui_image.h | 13 ++++-- sdk/meow_ui_img.c | 20 ++++++-- tuku/logic/reader/page_source.c | 23 +++++++-- tuku/logic/reader/page_source.h | 3 +- tuku/test/make_mem_samples.py | 34 ++++++++++++++ tuku/test/test_reader_many_pages.c | 42 ++++++++++------- tuku/test/test_reader_membudget.c | 75 +++++++++++++++++++++++------- tuku/ui/reader/reader.c | 10 ++-- 8 files changed, 171 insertions(+), 49 deletions(-) diff --git a/sdk/include/meow_ui_image.h b/sdk/include/meow_ui_image.h index 805400a..028a8f8 100644 --- a/sdk/include/meow_ui_image.h +++ b/sdk/include/meow_ui_image.h @@ -62,10 +62,15 @@ int meow_img_pick_scale(int longside, int maxdim); * 断言与实测见 tuku/test/test_reader_membudget.c 的 B7。 */ meow_img *meow_img_load_file_step(const char *path, int maxdim, int step); -/* 追加(M27):与 meow_img_load_file_step 完全相同,额外回报**实际**解码路径: - * used_backend = 1(M23 后端直出目标尺寸)/ 0(回退 stb)。阅读器用它把「门控预判」 - * 与「真实路径」对上日志;宿主测试据此断言后端命中。NULL 时等价 _step。 */ -meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, int *used_backend); +/* 追加(M27):与 meow_img_load_file_step 相同,额外控制回退并回报**实际**路径: + * allow_stb_fallback = 1:后端不支持/失败时回退 stb(与 _step 完全一致); + * allow_stb_fallback = 0:只尝试后端,失败返回 NULL——**绝不**回落 stb。阅读器的门控 + * 按「后端峰值」放行时必须用 0,否则头部判定通过、解码中途失败的文件会静默走 stb, + * 实测可放出 18–36MB(截断 12MP JPEG / 头-only BMP / 非法位深 PNG)。失败页由调用方 + * 按坏图占位。 + * used_backend = 1(M23 后端直出目标尺寸)/ 0(未走成后端)。NULL 时等价 _step(允许回退)。 */ +meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, + int allow_stb_fallback, int *used_backend); /* 追加(M22)精确重采样到 w×h(最近邻,缩小/放大都支持)。阅读器用它把「缩解档位」的 * 结果一次性落到显示框,使命中页仍是单次原生 blit(不做每帧重采样)。同尺寸返回 NULL diff --git a/sdk/meow_ui_img.c b/sdk/meow_ui_img.c index e4d34ad..4de3d31 100644 --- a/sdk/meow_ui_img.c +++ b/sdk/meow_ui_img.c @@ -101,18 +101,26 @@ meow_img *meow_img_load_file_dim(const char *path, int maxdim) /* 追加(M22):档位由调用方指定。与 meow_img_load_file_dim 同语义(结果长边 ≤ maxdim, * 超了再精修一次),只是档位不再由 maxdim 反推 —— 内存预算紧时可选更粗的档位。 * M27:真缩解后端优先(step 折算成目标长边 ceil(长边/step),后端按该长边选 DCT 阶, - * 峰值只与输出尺寸相关);后端不支持时回退 stbi_load_scaled(step),语义与 M22 一致。 */ -meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, int *used_backend) + * 峰值只与输出尺寸相关);后端不支持时回退 stbi_load_scaled(step),语义与 M22 一致。 + * + * M27 r1(P1-1):allow_stb_fallback=0 时只尝试后端,**绝不回退 stb**——阅读器的门控按 + * 「后端峰值」放行时必须走这里,否则「头部说支持、解码中途失败」会静默落到 stb, + * 实测可放出 18–36MB(截断 12MP JPEG / 头-only BMP / 非法位深 PNG),即 M22 的 SIGKILL 场景。 + * 后端失败返回 NULL(调用方按坏图占位)。 */ +meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, + int allow_stb_fallback, int *used_backend) { if (used_backend) *used_backend = 0; if (!path) return NULL; - if (step <= 1 && maxdim <= 0) return meow_img_load_file(path); + if (step <= 1 && maxdim <= 0) + return allow_stb_fallback ? meow_img_load_file(path) : NULL; if (step <= 0) step = 1; if (step > 8) step = 8; int w = 0, h = 0, c = 0; if (!stbi_info(path, &w, &h, &c)) return NULL; int longside = w > h ? w : h; - if (step == 1 && maxdim <= 0) return meow_img_load_file(path); + if (step == 1 && maxdim <= 0) + return allow_stb_fallback ? meow_img_load_file(path) : NULL; /* 追加(M23/M27):真缩解后端优先(JPEG/PNG/BMP 全部格式)。step 是「输出别比这个档位 * 更细」的提示:折算成目标长边 ceil(长边/step) 传给后端(后端按该长边自动选 DCT 阶), * 与下面 stbi_load_scaled(step) 的语义一致,峰值只与输出尺寸相关;maxdim<=0 时不精修, @@ -130,6 +138,7 @@ meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, int return img_finish_dim(bp, bw, bh, maxdim > 0 ? maxdim : longside); } } + if (!allow_stb_fallback) return NULL; /* P1-1:门控按后端放行的页,失败即坏图 */ unsigned char *p = stbi_load_scaled(path, &w, &h, &c, 4, step); /* maxdim<=0:不精修(保留档位输出的原始尺寸)。 */ return img_finish_dim(p, w, h, maxdim > 0 ? maxdim : longside); @@ -137,7 +146,8 @@ meow_img *meow_img_load_file_step_ex(const char *path, int maxdim, int step, int meow_img *meow_img_load_file_step(const char *path, int maxdim, int step) { - return meow_img_load_file_step_ex(path, maxdim, step, NULL); + /* 通用入口保持 M22 语义:后端失败允许回退 stb。 */ + return meow_img_load_file_step_ex(path, maxdim, step, 1, NULL); } /* 追加(M22):精确重采样到目标尺寸(最近邻,双向)。阅读器把缩解档位的结果一次性 diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c index 8a0c9e1..bed0546 100644 --- a/tuku/logic/reader/page_source.c +++ b/tuku/logic/reader/page_source.c @@ -486,6 +486,16 @@ static int mr_png_bits(int color, int depth) } } +/* 位深合法性(与 meow_png_stream.c 的 png_depth_ok **逐字同式**,P2-1): + * color 0/3 允许 1/2/4/8(灰度再加 16),color 2/4/6 只允许 8/16。 */ +static int mr_png_depth_ok(int color, int depth) +{ + if (color == 0) return depth == 1 || depth == 2 || depth == 4 || depth == 8 || depth == 16; + if (color == 3) return depth == 1 || depth == 2 || depth == 4 || depth == 8; + if (color == 2 || color == 4 || color == 6) return depth == 8 || depth == 16; + return 0; +} + /* 后端支持矩阵(镜像 meow_jpeg.c / meow_png_stream.c 的拒绝条件;字段未知一律不支持)。 * eff 是后端实际会收到的 maxdim(见 mr_effective_dim)。 */ static int mr_backend_supported(const mr_img_info *in, int eff) @@ -511,7 +521,7 @@ static int mr_backend_supported(const mr_img_info *in, int eff) case MR_IMG_CODEC_PNG: { if (in->interlace || in->compression || in->filter) return 0; if (in->w > 65535 || in->h > 65535) return 0; - if (mr_png_bits(in->color, in->depth) == 0) return 0; + if (!mr_png_depth_ok(in->color, in->depth)) return 0; return 1; } case MR_IMG_CODEC_BMP: @@ -648,12 +658,19 @@ void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, int longside = in->w > in->h ? in->w : in->h; int eff = mr_effective_dim(longside, step, dim); - /* 1) 先看后端能否接住:命中 → 直接放行(后端峰值与源尺寸无关,不需要门控)。 */ + /* 1) 先看后端能否接住:命中时峰值与源尺寸无关(典型页 <2.6MB),不做 3MB 常态门控; + * 但仍保留两道安全阀(P2-2):MemAvailable <6MB 直接占位、估算超过 12MB 硬上限占位。 + * 后端路由由 reader 用 backend-only 入口执行(P1-1),失败即坏图,绝不回落 stb。 */ if (mr_backend_supported(in, eff)) { out->path = MR_DECODE_PATH_BACKEND; out->peak_bytes = mr_backend_peak_bytes(in, eff, step, box_w, box_h, &out->tw, &out->th); - out->skip = MR_DECODE_OK; + if (memavail_kb >= 0 && memavail_kb < MR_MEM_FLOOR_KB) + out->skip = MR_DECODE_SKIP_LOWMEM; + else if (out->peak_bytes > MR_PEAK_HARD_BYTES) + out->skip = MR_DECODE_SKIP_PEAK; + else + out->skip = MR_DECODE_OK; return; } diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h index fab2893..024a959 100644 --- a/tuku/logic/reader/page_source.h +++ b/tuku/logic/reader/page_source.h @@ -140,7 +140,8 @@ int mr_probe_info_mem(const void *buf, size_t len, mr_img_info *info); * * 参数:step 与 meow_img_load_file_step 同义(JPEG 档位提示);box_w/box_h 是阅读器 * 最终显示框;memfree_kb/memavail_kb 读不到时传 -1。out 必须非 NULL。 - * 估算口径保证 ≥ 实测(--wrap malloc)且不超过实测 1.5×(见 tuku/test 断言)。 */ + * 估算精度口径:**后端命中页 / 典型页**保证估算 ≥ 实测(--wrap malloc)且 ≤1.5×; + * 回退 stb 的小图只保证门控安全(可能保守,不强求 1.5× 上界)。断言见 tuku/test。 */ #define MR_DECODE_PATH_STB 0 #define MR_DECODE_PATH_BACKEND 1 diff --git a/tuku/test/make_mem_samples.py b/tuku/test/make_mem_samples.py index 928c3b0..8c3f0ee 100644 --- a/tuku/test/make_mem_samples.py +++ b/tuku/test/make_mem_samples.py @@ -93,6 +93,40 @@ def main(): big.convert("CMYK").save(os.path.join(out, "cmyk.jpg"), "JPEG", quality=60) write_adam7(os.path.join(out, "adam7.png"), pattern(1600, 2400)) pattern(1600, 2400).convert("1").save(os.path.join(out, "mono1.bmp"), "BMP") + + # ---- M27 r1(P1-1):头部判支持但解码中途失败/后端拒绝的坏文件 ---- + # 1) 截断的 12MP JPEG:SOF 仍在(probe/矩阵判 backend),熵段被腰斩 → 后端失败; + # 旧实现会静默回退 stb 并分配 ~18MB。 + with open(os.path.join(out, "mp12.jpg"), "rb") as f: + blob = f.read(200000) + with open(os.path.join(out, "trunc2000.jpg"), "wb") as f: + f.write(blob) + # 2) 3000x3000 24bpp BMP,只有 54 字节头(无像素):矩阵判 backend,后端失败; + # 旧实现回退 stb 会 malloc 36MB 并"解出"一张全黑图。 + hdr = bytearray(54) + hdr[0:2] = b"BM" + struct.pack_into("IIBBBBB", w, h, 1, 2, 0, 0, 0) + data = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw))) + chunk(b"IEND", b"")) + with open(os.path.join(out, "baddepth.png"), "wb") as f: + f.write(data) return 0 diff --git a/tuku/test/test_reader_many_pages.c b/tuku/test/test_reader_many_pages.c index 463d80c..4f2b3da 100644 --- a/tuku/test/test_reader_many_pages.c +++ b/tuku/test/test_reader_many_pages.c @@ -85,9 +85,13 @@ static int write_garbage(const char *path) } /* 合法 BMP24 头(声称 3000x3000,无像素数据):stbi_info 能读到尺寸, - * 整幅 RGBA 峰值 36MB 必须被 MR_DECODE_PEAK_CAP(12MB) 在解码前拦下 - * (旧代码的 raw 上限是 40MB,这种页会被放行 → 真机上瞬时分配即可能被 OOM 杀掉)。 */ -static int write_huge_bmp_header(const char *path) + * 整幅 RGBA 峰值 36MB 必须在解码前拦下(旧代码的 raw 上限是 40MB,这种页会被放行 → + * 真机上瞬时分配即可能被 OOM 杀掉)。 + * M27 r1:24bpp(后端支持)与 1bpp(后端拒绝→stb)两种都要覆盖: + * - 24bpp 头-only:判定 backend → backend-only 失败 → NULL/坏图占位,峰值必须 ≤3MB; + * - 1bpp 头-only:判定 stb → 旧门控 skip=peak,零分配。 + * 旧实现把 24bpp 换成 1bpp 恰好绕开了「矩阵接受、解码失败→静默回退 stb」的 P1 旁路。 */ +static int write_huge_bmp_header(const char *path, int bpp) { unsigned char b[54] = { 0 }; b[0] = 'B'; b[1] = 'M'; @@ -97,7 +101,7 @@ static int write_huge_bmp_header(const char *path) put32(b + 18, 3000); /* width */ put32(b + 22, 3000); /* height */ put16(b + 26, 1); /* planes */ - put16(b + 28, 1); /* bpp=1:M23 后端不支持 → 回退 stb(M27 门控仍生效) */ + put16(b + 28, (unsigned)bpp); /* 24=后端支持但无像素数据;1=后端拒绝→stb */ FILE *f = fopen(path, "wb"); if (!f) return -1; int ok = fwrite(b, 1, sizeof b, f) == sizeof b; @@ -177,12 +181,15 @@ int main(void) snprintf(p, sizeof p, "%s/p%03d.bmp", book, i + 1); if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } } - /* 坏册:第 2 页是垃圾字节(解码失败 → 占位,不许崩/死循环)。 */ - for (int i = 0; i < 4; i++) { + /* 坏册(5 页):1=有效;2=垃圾字节(永久 failed);3=3000×3000 24bpp 头-only + * (判定 backend→后端失败→NULL,永久 failed,峰值必须小);4=3000×3000 1bpp 头-only + * (回退 stb→skip=peak,可重试);5=有效。 */ + for (int i = 0; i < 5; i++) { char p[MR_PATH_MAX]; snprintf(p, sizeof p, "%s/p%d.bmp", bad, i + 1); if (i == 1) { if (write_garbage(p) != 0) { printf("SKIP: write failed\n"); return 0; } } - else if (i == 2) { if (write_huge_bmp_header(p) != 0) { printf("SKIP: write failed\n"); return 0; } } + else if (i == 2) { if (write_huge_bmp_header(p, 24) != 0) { printf("SKIP: write failed\n"); return 0; } } + else if (i == 3) { if (write_huge_bmp_header(p, 1) != 0) { printf("SKIP: write failed\n"); return 0; } } else if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } } @@ -259,7 +266,7 @@ int main(void) CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded on 140p book (%ldKB)", mr_reader_ui_resident_kb()); - /* 坏图册:翻过坏页不崩、可继续翻。 */ + /* 坏图册(5 页):翻过坏页不崩、可继续翻。最后一页是有效页,允许不前进。 */ meow_view_page_pop(); mr_reader_open_dir_at(bad, 0); pump_ms(600); @@ -268,18 +275,21 @@ int main(void) int before = mr_reader_ui_page(); tap(40, 300); pump_ms(300); - if (mr_reader_ui_page() == before && before < 3) bad_ok = 0; + if (mr_reader_ui_page() == before && before < 4) bad_ok = 0; } CHECK(bad_ok, "flip across corrupt/huge pages keeps working (page=%d)", mr_reader_ui_page()); CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, - "huge page skipped without allocating it (%ldKB)", mr_reader_ui_resident_kb()); - /* M22/M27:坏图 = 永久占位(failed);超预算的**回退路径**大页 = 预算跳过(skipped, - * 可重试,不是 failed)。M27 起后端能接住的 24bpp 大图会直接解出,所以这里用 1bpp - * (后端拒绝 → stb)保证仍在验证门控,而不是「大图一律占位」的旧语义。 */ - CHECK(mr_reader_ui_failed_pages() == 1, - "garbage page is a permanent placeholder (failed=%d)", mr_reader_ui_failed_pages()); + "huge pages skipped/rejected without allocating them (%ldKB)", mr_reader_ui_resident_kb()); + /* M22/M27 r1:坏图 = 永久占位(failed);超预算的**回退路径**大页 = 预算跳过(skipped)。 + * - 垃圾页 + 3000×3000 24bpp 头-only(判定 backend,backend-only 失败→NULL)→ failed=2; + * - 3000×3000 1bpp 头-only(判定 stb,超预算)→ skipped=1。 + * 这条 24bpp 夹具正是 P1 旁路的复现:旧实现会静默回退 stb 并分配 36MB。 */ + CHECK(mr_reader_ui_failed_pages() == 2, + "garbage + 24bpp head-only are permanent placeholders (failed=%d)", + mr_reader_ui_failed_pages()); CHECK(mr_reader_ui_skipped_pages() == 1, - "oversized page is a retryable budget skip (skipped=%d)", mr_reader_ui_skipped_pages()); + "1bpp oversized page is a retryable budget skip (skipped=%d)", + mr_reader_ui_skipped_pages()); CHECK(meow_ui_activity_active() == ACT_READER, "still in reader after corrupt page"); /* ---- M5 条漫:140 页排版 + 跳页落位 ---- */ diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index e4897bb..e83cae6 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -15,7 +15,9 @@ * B7 M27:6 类典型页(2000×3000 baseline/渐进/restart、1600×2400 PNG/BMP、4MP、12MP) * 全部走后端、不占位;估算 ≥ 实测且 ≤1.5×;输出尺寸与显示框一致 * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 - * B8 M27:连翻 30 页(6 类循环)不掉页、不占位、分配峰值 ≤3MB + * B7c P1-1 旁路回归:截断 12MP JPEG / 3000×3000 24bpp 头-only BMP(判定 backend, + * 后端失败必须 NULL、峰值 ≤3MB)/ 非法位深 PNG(矩阵拒绝→stb 占位) + * B8 M27:连翻 30 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 @@ -374,6 +376,7 @@ static int gen_jpeg_fixtures(const char *dir) "base2000.jpg", "prog2000.jpg", "restart2000.jpg", "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", "cmyk.jpg", "adam7.png", "mono1.bmp", + "trunc2000.jpg", "hdr3000.bmp", "baddepth.png", }; for (size_t i = 0; i < sizeof need / sizeof need[0]; i++) { char p[MR_PATH_MAX]; @@ -384,15 +387,17 @@ static int gen_jpeg_fixtures(const char *dir) } /* 与 reader_load_page 完全同路径:_step_ex → 兜底降采样 → 落框重采样。 + * allow_stb 与阅读器一致:判定 backend 时传 0(后端失败即 NULL,绝不回退 stb)。 * 返回真实分配峰值(字节),并回报实际路径与最终尺寸。 */ static size_t measure_page_decode(const char *path, const mr_img_info *in, int step, - int box_w, int box_h, int *used_backend, int *ow, int *oh) + int allow_stb, int box_w, int box_h, + int *used_backend, int *ow, int *oh) { int dim = box_w > box_h ? box_w : box_h; size_t base = g_live; peak_reset(); int used = 0; - meow_img *im = meow_img_load_file_step_ex(path, dim, step, &used); + meow_img *im = meow_img_load_file_step_ex(path, dim, step, allow_stb, &used); if (im && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } @@ -633,7 +638,7 @@ int main(void) mr_decode_decision dec; reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); int used = 0, ow = 0, oh = 0; - size_t measured = measure_page_decode(p, &in, dec.step, box_w, box_h, + size_t measured = measure_page_decode(p, &in, dec.step, 1, box_w, box_h, &used, &ow, &oh) + 1; printf(" %-16s %-8s %10lld %10zu %6.2f\n", typical[i], dec.path == MR_DECODE_PATH_BACKEND ? "backend" : "stb", @@ -664,7 +669,7 @@ int main(void) int step = in.codec == MR_IMG_CODEC_JPEG ? meow_img_pick_scale(in.w > in.h ? in.w : in.h, dim) : 1; int used = 0, ow = 0, oh = 0; - measure_page_decode(p, &in, step, box_w, box_h, &used, &ow, &oh); + measure_page_decode(p, &in, step, 1, box_w, box_h, &used, &ow, &oh); /* 旧路径(与 meow_img_load_file_step 的 stb 分支同式):先 stbi 缩解,再精修。 */ int sw = 0, sh = 0, sc = 0, ew = 0, eh = 0; unsigned char *raw = stbi_load_scaled(p, &sw, &sh, &sc, 4, step); @@ -701,7 +706,7 @@ int main(void) mr_decode_decision dec; reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); int used = 1, ow = 0, oh = 0; - size_t measured = measure_page_decode(p, &in, dec.step, box_w, box_h, + size_t measured = measure_page_decode(p, &in, dec.step, 1, box_w, box_h, &used, &ow, &oh) + 1; printf(" %-16s %-8s %10lld %10zu %6.2f\n", fallback[i], dec.path == MR_DECODE_PATH_BACKEND ? "backend" : "stb", @@ -715,6 +720,44 @@ int main(void) fallback[i]); if (ow > 0 && oh > 0) {} /* 尺寸仅记录 */ } + /* ---- B7c(P1-1 回归):头部判支持、解码中途失败的坏文件 ---- + * 旧实现「后端失败 → 静默回退 stb」实测放出 16–36MB;现在必须: + * - 判定 backend 的(截断 JPEG / 头-only 24bpp BMP)→ backend-only 解码返回 NULL, + * 峰值只含失败前的小分配(≤3MB),绝不 stb 整幅; + * - 判定 stb 的(非法位深 PNG,矩阵已镜像 png_depth_ok)→ skip=peak,不分配。 */ + static const char *const badmeta[] = { "trunc2000.jpg", "hdr3000.bmp", "baddepth.png" }; + printf(" [B7c] 坏文件(P1-1 旁路回归):\n"); + for (size_t i = 0; i < sizeof badmeta / sizeof badmeta[0]; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", jpgbook, badmeta[i]); + mr_img_info in; + if (!mr_probe_info(p, &in)) { CHECK(0, "probe %s", badmeta[i]); continue; } + int box_w = 480; + int box_h = (int)((long)in.h * box_w / in.w); + int dim = box_w > box_h ? box_w : box_h; + int step = in.codec == MR_IMG_CODEC_JPEG + ? meow_img_pick_scale(in.w > in.h ? in.w : in.h, dim) : 1; + mr_decode_decision dec; + reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); + if (dec.path == MR_DECODE_PATH_BACKEND) { + int used = 1, ow = 0, oh = 0; + size_t measured = measure_page_decode(p, &in, dec.step, 0, box_w, box_h, + &used, &ow, &oh) + 1; + printf(" %-16s backend-only measured=%zuKB (backend 失败→NULL)\n", + badmeta[i], measured / 1024); + CHECK(used == 0, "%s 不走 stb(used=0)", badmeta[i]); + CHECK(ow == 0 && oh == 0, "%s 返回 NULL(坏图占位)", badmeta[i]); + CHECK(measured <= 3 * 1024 * 1024, + "%s backend-only 峰值 ≤3MB (%zuKB)", badmeta[i], measured / 1024); + } else { + printf(" %-16s stb skip=%d est=%lldkB(门控占位,不分配)\n", + badmeta[i], dec.skip, dec.peak_bytes / 1024); + CHECK(dec.skip != MR_DECODE_OK, "%s 门控必须占位 (skip=%d)", + badmeta[i], dec.skip); + CHECK(dec.peak_bytes > 3LL * 1024 * 1024, + "%s stb 模型判超预算 (%lldkB)", badmeta[i], dec.peak_bytes / 1024); + } + } } else { printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } @@ -728,32 +771,30 @@ int main(void) int w = meow_ui_width(); int advanced = 0, skipped_max = 0; size_t live_steady = g_live; - size_t peak_steady = g_peak; int d0 = mr_reader_ui_decode_count(); - size_t peak_max = 0; + /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 + * 「分配新图 + 解码」的真实峰值;指标 = 整个 30 翻窗口内相对稳态基线的最大存活增量。 + * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ + size_t base = peak_reset(); for (int i = 0; i < 30; i++) { int before = mr_reader_ui_page(); - size_t base = g_live; - peak_reset(); /* 每页单独量「相对当前存活」的瞬时增量 */ tap(i % 2 == 0 ? 40 : w - 40, 300); pump_ms(220); - size_t step_peak = g_peak > base ? g_peak - base : 0; - if (step_peak > peak_max) peak_max = step_peak; if (mr_reader_ui_page() != before) advanced++; if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); } - size_t transient = g_peak > peak_steady ? g_peak - peak_steady : 0; + size_t transient = g_peak > base ? g_peak - base : 0; INFO("30 mixed flips: advanced=%d skipped=%d decodes=%d steady=%zuKB " - "max_step_peak=%zuKB cumulative=%zuKB", + "resident=%ldKB max_live_delta=%zuKB", advanced, skipped_max, mr_reader_ui_decode_count() - d0, - live_steady / 1024, peak_max / 1024, transient / 1024); + live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); CHECK(advanced == 30, "30 页全部翻到 (%d/30)", advanced); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); - CHECK(peak_max <= 3 * 1024 * 1024, - "30 页连翻单页分配峰值 ≤3MB (%zuKB)", peak_max / 1024); + CHECK(transient <= 3 * 1024 * 1024, + "30 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); meow_view_page_pop(); pump_ms(200); } diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 24b528a..ded7039 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -490,13 +490,17 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) } /* 2) 解码(路径/档位已定)。失败与「跳过」必须分开:NULL + skip==0 才是坏图。 - * step_ex 回报实际路径,日志据此区分 backend / stb(决策与实际必须一致)。 */ + * P1-1:门控按后端放行的页必须用 backend-only 入口——后端中途失败(截断/坏流/ + * DHT 超限/malloc 失败)直接返回 NULL 按坏图占位,绝不静默回落 stb(实测会放出 + * 18–36MB)。只有判定为 stb 的页才允许 stb。 */ + int allow_stb = !have || dec.path != MR_DECODE_PATH_BACKEND; int used_backend = 0; - meow_img *im = have ? meow_img_load_file_step_ex(path, dim, dec.step, &used_backend) + meow_img *im = have ? meow_img_load_file_step_ex(path, dim, dec.step, allow_stb, + &used_backend) : meow_img_load_file_dim(path, dim); if (!im) { log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail", - dec.step, used_backend, + dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } From 606a04a44e2339b895a15d4838c6a46868a16c7c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 21:49:21 +0800 Subject: [PATCH 29/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=94=81=E5=B1=8F?= =?UTF-8?q?=20PIN=20=E6=94=B9=E9=A1=B5=E5=86=85=E8=87=AA=E7=BB=98=2012=20?= =?UTF-8?q?=E9=94=AE=E6=95=B0=E5=AD=97=E9=94=AE=E7=9B=98=EF=BC=88=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=BC=B9=E7=B3=BB=E7=BB=9F=20IME=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 goodix 抬手长尾会让阻塞式 MEOW_IME_NUM 打开即被「点背景=取消」 关掉,表现为「PIN 按钮点好几下才弹键盘」。锁屏改自绘 3×4 键盘 (1-9 / 退格·0·确认,键 137×96、间距 10px、底边留 28px 上滑热区), 完全离线、无 IME 会话,首次点按必响应。 - 4-8 位(MR_PIN_MIN_LEN/MAX_LEN),顶部星号显示已输入位数,不回显明文; 满 4 位后「确认」才可用;错误→提示+清空;通过→page_pop 回书库 - 长按退格清空;退格空缓冲为 no-op;8 位上限截断 - 颜色全部取 meow_theme_get();按键走 meow_btn_create_variant(TONAL/ OUTLINED/FILLED),沿用 SDK 按压高亮/禁用变淡 - 锁屏仍消费全部 SWIPE/TAP/LONG_PRESS(左缘绕过 PIN 的路径不变), 物理键返回 0 交 app.c on_key 仅日志 - 与设置页共用 logic/pin 的 mr_pin_verify/mr_pin_set,不复制校验逻辑 - 日志:lock: pad ready / pin wrong / pin ok(真机验收判据) test_settings_lock_host.c:锁屏用例从 IME 注入改为按键点击(12 键归属、 键高/间距/热区几何、空确认/不足 4 位、错 PIN 清空重输、8 位上限+退格、 空退格、长按清空、不打开系统 IME);设置页仍走真实 IME 注入路径。 --- tuku/test/test_settings_lock_host.c | 129 ++++++++++++-- tuku/ui/lock/lock.c | 266 ++++++++++++++++++++-------- 2 files changed, 303 insertions(+), 92 deletions(-) diff --git a/tuku/test/test_settings_lock_host.c b/tuku/test/test_settings_lock_host.c index 0d3fb60..93cebd7 100644 --- a/tuku/test/test_settings_lock_host.c +++ b/tuku/test/test_settings_lock_host.c @@ -8,8 +8,11 @@ * 的 on_swipe),否则左缘返回会把 ACT_LOCK page_pop 掉、PIN 被绕过。 * 另外覆盖:设置页回到书库(topbar 返回)、控件跨进出复用不重建、阅读方向经 * mr_reader_set_direction 切换、lock_view_should_show() 随 PIN 状态变化、 - * 主题模式切换后整帧重绘不崩,以及**真实 MEOW_IME_NUM 键盘**驱动的两条端到端路径: - * 锁屏错 PIN(留在锁屏)→ 对 PIN(page_pop 回书库)、设置页两遍输入开启 PIN。 + * 主题模式切换后整帧重绘不崩,以及两条端到端路径: + * - **锁屏页内自绘 12 键数字键盘**(1-9 / 退格·0·确认):错 PIN 留在锁屏 → 对 PIN + * page_pop 回书库;位数不足确认不可用、超长输入截断、退格/长按清空、错误后清空重输; + * 键高/间距/屏底 28px 热区几何契约; + * - 设置页两遍输入开启 PIN(仍走真实 MEOW_IME_NUM 键盘注入)。 * * 编译:见 test/run_tests.sh —— 它把本测试、settings/lock/pin 源码与 SDK 源码 * (-DMEOW_UI_HOST_TEST,附 test_host_stub.c 打桩 font/global)一起编;SDK 对象清单在脚本里。 @@ -88,15 +91,44 @@ static void *pin_injector(void *arg) return NULL; } -static void lock_try_pin(const char *digits) +/* ============================================================ */ +/* 锁屏页内自绘 12 键数字键盘(宿主没有真实触摸):按 widget 矩形中心投递 TAP。 + * 键下标 = lock.c 创建序:0..8 = 1..9,9 = 退格,10 = 0,11 = 确认(LK_K*)。 + * ============================================================ */ +enum { LK_K1 = 0, LK_K9 = 8, LK_KBACK = 9, LK_K0 = 10, LK_KOK = 11 }; + +static int lock_key_center(meow_view *lock, int idx, int *cx, int *cy) { - int sb = meow_ui_statusbar_height(); - int cy = sb + (meow_ui_height() - sb) / 2; - pin_job job = { digits, 1 }; - pthread_t th; - if (pthread_create(&th, NULL, pin_injector, &job) != 0) return; - meow_view_dispatch_event(MEOW_EV_TAP, 240, cy + 40); /* 点「输入 PIN」按钮 */ - pthread_join(th, NULL); + int r[4] = { 0 }; + if (!lock || idx < 0 || idx >= lock->nwidgets || !lock->widgets[idx]) return 0; + meow_widget_rect(lock->widgets[idx], r); + *cx = r[0] + r[2] / 2; + *cy = r[1] + r[3] / 2; + return 1; +} + +static void lock_tap_key(meow_view *lock, int idx) +{ + int cx, cy; + if (lock_key_center(lock, idx, &cx, &cy)) + meow_view_dispatch_event(MEOW_EV_TAP, cx, cy); +} + +static int lock_key_idx(char d) +{ + return (d == '0') ? LK_K0 : (d - '1'); +} + +static void lock_type(meow_view *lock, const char *digits) +{ + for (const char *s = digits; *s; s++) lock_tap_key(lock, lock_key_idx(*s)); +} + +static void lock_long_back(meow_view *lock) +{ + int cx, cy; + if (lock_key_center(lock, LK_KBACK, &cx, &cy)) + meow_view_dispatch_event(MEOW_EV_LONG_PRESS, cx, cy); } static void settings_tap_pin_row(void){ @@ -182,8 +214,31 @@ int main(void) meow_view_page_push(ACT_LOCK); CHECK(meow_ui_activity_active() == ACT_LOCK, "lock active"); - CHECK(lock->nwidgets == 1, "lock built its button on enter (got %d)", lock->nwidgets); - CHECK(meow_widget_owner(lock->widgets[0]) == ACT_LOCK, "lock button owned by ACT_LOCK"); + CHECK(lock->nwidgets == 12, "lock built 12-key pad on enter (got %d)", lock->nwidgets); + int lock_owner_ok = 1; + for (int i = 0; i < lock->nwidgets; i++) + if (meow_widget_owner(lock->widgets[i]) != ACT_LOCK) lock_owner_ok = 0; + CHECK(lock_owner_ok, "pad keys owned by ACT_LOCK (no cross-page leak)"); + + /* 几何契约:键 ≥64px 高、间距 ≥8px、不覆盖屏底 28px 上滑热区、全在屏内。 */ + int rk[12][4]; + for (int i = 0; i < 12; i++) meow_widget_rect(lock->widgets[i], rk[i]); + int geo_ok = 1, gap_ok = 1, hot_ok = 1; + for (int i = 0; i < 12; i++) { + if (rk[i][2] < 64 || rk[i][3] < 64) geo_ok = 0; + if (rk[i][0] < 0 || rk[i][0] + rk[i][2] > meow_ui_width()) geo_ok = 0; + if (rk[i][1] + rk[i][3] > meow_ui_height() - 28) hot_ok = 0; + } + /* 列间距(行内 0/1/2)与行间距(行 0 与行 3:下标 0 与 9)。 */ + if (rk[1][0] - (rk[0][0] + rk[0][2]) < 8) gap_ok = 0; + if (rk[2][0] - (rk[1][0] + rk[1][2]) < 8) gap_ok = 0; + if (rk[9][1] - (rk[0][1] + rk[0][3]) < 8) gap_ok = 0; + CHECK(geo_ok, "pad keys >=64px and within screen"); + CHECK(gap_ok, "pad gaps >=8px"); + CHECK(hot_ok, "pad leaves the 28px bottom swipe hot zone"); + printf(" pad geometry: key %dx%d, pad top=%d bottom=%d (screen %dx%d)\n", + rk[0][2], rk[0][3], rk[0][1], rk[11][1] + rk[11][3], + meow_ui_width(), meow_ui_height()); CHECK(meow_view_dispatch_event(MEOW_EV_SWIPE, MEOW_SWIPE_LEFT, 240) == 1, "lock consumes SWIPE (left-edge back cannot bypass PIN)"); @@ -194,11 +249,53 @@ int main(void) CHECK(mr_pin_verify("1357") == 1, "stored PIN verifies"); CHECK(mr_pin_verify("1358") == 0, "wrong PIN rejected"); - /* ---- 锁屏真解锁路径:点按钮 → MEOW_IME_NUM 输入 → 校验 → pop(错误则留在锁屏) ---- */ - lock_try_pin("1358"); /* 错 PIN:留在锁屏 */ + /* 空输入 / 位数不足:确认不可用(不误判、不退出锁屏)。 */ + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LOCK, "empty confirm keeps lock"); + lock_type(lock, "135"); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LOCK, "confirm disabled below 4 digits"); + + /* 第 4 位后确认可用:1-3-5-7 → 回书库。 */ + lock_tap_key(lock, lock_key_idx('7')); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "4-digit correct PIN pops lock to library"); + CHECK(meow_ime_active() == 0, "in-page pad never opens the system IME"); + + /* 错 PIN:留在锁屏 + 清空;再输对 → 解锁(证明错误后缓冲已清空)。 */ + meow_view_page_push(ACT_LOCK); + lock_type(lock, "1358"); + lock_tap_key(lock, LK_KOK); CHECK(meow_ui_activity_active() == ACT_LOCK, "wrong PIN keeps lock (retry allowed)"); - lock_try_pin("1357"); /* 正确:page_pop 回书库 */ - CHECK(meow_ui_activity_active() == ACT_LIBRARY, "correct PIN pops lock to library"); + lock_type(lock, "1357"); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, + "retry after wrong PIN unlocks (buffer cleared on error)"); + + /* 8 位上限:9 个键只收前 8 位;退格 4 次回到 "1357" → 解锁。 */ + meow_view_page_push(ACT_LOCK); + lock_type(lock, "135713579"); + for (int i = 0; i < 4; i++) lock_tap_key(lock, LK_KBACK); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, + "9th digit ignored (max 8) + backspace removes digits"); + + /* 退格空缓冲边界:多按不崩,随后正常输入。 */ + meow_view_page_push(ACT_LOCK); + for (int i = 0; i < 3; i++) lock_tap_key(lock, LK_KBACK); + lock_type(lock, "1357"); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "backspace on empty buffer is a no-op"); + + /* 长按退格清空:先输 3 位 → 长按清空 → 再输 4 位正确 PIN 才解锁。 */ + meow_view_page_push(ACT_LOCK); + lock_type(lock, "135"); + lock_long_back(lock); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LOCK, "long-press backspace cleared input"); + lock_type(lock, "1357"); + lock_tap_key(lock, LK_KOK); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "unlock after long-press clear"); /* ---- 设置页两遍输入开启 PIN(MEOW_IME_NUM) ---- */ mr_pin_disable(); diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c index 37827c8..542d6ff 100644 --- a/tuku/ui/lock/lock.c +++ b/tuku/ui/lock/lock.c @@ -1,18 +1,25 @@ -/* ui/lock/lock.c — 锁屏页(ACT_LOCK):APP 启动 PIN。 +/* ui/lock/lock.c — 锁屏页(ACT_LOCK):APP 启动 PIN(页内自绘数字键盘)。 * * 流程(用户原话「允许用户在APP设置中设置APP启动 PIN」): * - lock_view_should_show() = logic/pin 的 mr_pin_enabled();app.c 据此在启动时先 push * 本页,验证通过前书库不进入/不绘制(view 注册表只画已进入的页,widget 还按 owner 过滤); - * - 本页 on_enter 建「输入 PIN」按钮(owner=ACT_LOCK),点按弹系统数字键盘(MEOW_IME_NUM), - * 校验通过 → page_pop 回书库;错误 → 就地提示重试(不做惩罚性锁定/计数); + * - 本页 on_enter 建 12 键数字键盘(1-9 / 退格·0·确认,owner=ACT_LOCK)。**不再调系统 + * 输入法**:真机 goodix 抬手长尾会让 MEOW_IME_NUM 打开即被「点背景=取消」关掉,表现为 + * 「点好几下才弹键盘」;页内键盘完全离线、无 IME 会话,首次点按必响应; + * - 4-8 位数字(logic/pin 的 MR_PIN_MIN_LEN/MAX_LEN),顶部只显示星号/圆点数位,不回显明文; + * 满 4 位后「确认」才可用;错误 → 清空 + 提示;通过 → page_pop 回书库; + * - 长按「退格」清空已输入(不做惩罚性计数/锁定); * - 左缘返回**必须**由本页 on_event 消费 SWIPE(meow_view_dispatch_event 先于 app.c 的 - * on_swipe;否则 app.c 对非 library 页无条件 page_pop,PIN 会被绕过); + * on_swipe;否则 app.c 对非 library 页无条件 page_pop,PIN 会被绕过);TAP/LONG_PRESS + * 也一律消费,物理键不接(返回 0,交 app.c 的 on_key 仅日志);屏底 28px 上滑热区 + * 不被键盘覆盖(键盘底边留 28+12px); * - PIN 未启用时 app.c 直接 push 书库,本页不会被进入。 * * 安全口径(诚实标注):PIN 防的是随手翻看,不是安全边界——TF 卡可拔出直读,配置目录 * 可改。存储为加盐迭代哈希(logic/pin.c),不引入加密库;锁屏文案也如实告知用户。 * * 控件归属(§6.1,M3 桩注释①):on_create 时 activity 栈为空,控件一律在 on_enter 建。 + * 键位顺序(宿主测试依赖):1..9、退格、0、确认 = 下标 0..11。 */ #include "tuku_internal.h" #include "logic/pin.h" @@ -20,19 +27,35 @@ #include #include +#define LK_KEY_COUNT 12 +#define LK_GRID_COLS 3 +#define LK_GRID_ROWS 4 +#define LK_GAP 10 /* 键间距(≥8;触摸目标互不粘连) */ +#define LK_KEY_MIN_H 64 /* 最小键高 */ +#define LK_KEY_MAX_H 96 +#define LK_KEY_MAX_W 160 +#define LK_SIDE 24 /* 键盘左右留白 */ +#define LK_BOTTOM_HOT 28 /* 屏底上滑回桌面热区:键盘不得覆盖 */ +#define LK_BOTTOM_PAD 12 /* 再留一点呼吸,避免贴住热区 */ +#define LK_HEAD_MIN 220 /* 键盘上方留给标题/圆点/提示的最小高度 */ + +/* 键下标 = 创建序(宿主测试依赖此序)。 */ +enum { LK_K1 = 0, LK_K9 = 8, LK_KBACK = 9, LK_K0 = 10, LK_KOK = 11 }; + static mr_page_ctrl g_ctrl; -static meow_widget *g_btn; +static meow_widget *g_keys[LK_KEY_COUNT]; +static char g_pin[MR_PIN_MAX_LEN + 1]; +static int g_pin_len; static char g_msg[64]; static int g_msg_err; -static int g_waiting; /* 1=数字键盘打开中(上部提示改为输入说明) */ -static char g_ime_pin[MR_PIN_MAX_LEN + 12]; /* meow_view on_event 的事件码:公共伞头未导出(sdk/meow_ui_internal.h: - * MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3,值稳定),此处按契约镜像。 */ -#define MR_VEV_TAP 2 -#define MR_VEV_SWIPE 3 + * MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3 / MEOW_EV_LONG_PRESS=8,值稳定),按契约镜像。 */ +#define MR_VEV_TAP 2 +#define MR_VEV_SWIPE 3 +#define MR_VEV_LONG_PRESS 8 int lock_view_should_show(void) { @@ -40,56 +63,112 @@ int lock_view_should_show(void) } /* ============================================================ */ -/* PIN 输入(系统数字键盘) */ +/* 几何(480×720:键 ~137×96,四行 + 屏底热区留白) */ /* ============================================================ */ -static void ime_pin_change(const char *text, void *ctx) +static void lock_keypad_geom(int *x0, int *y0, int *kw, int *kh) { - (void)ctx; - snprintf(g_ime_pin, sizeof g_ime_pin, "%s", text ? text : ""); + int w = meow_ui_width(); + int h = meow_ui_height(); + int sb = meow_ui_statusbar_height(); + int bottom = h - LK_BOTTOM_HOT - LK_BOTTOM_PAD; + int top_min = sb + LK_HEAD_MIN; + + int kw_ = (w - 2 * LK_SIDE - (LK_GRID_COLS - 1) * LK_GAP) / LK_GRID_COLS; + if (kw_ > LK_KEY_MAX_W) kw_ = LK_KEY_MAX_W; + if (kw_ < LK_KEY_MIN_H) kw_ = LK_KEY_MIN_H; + + int kh_ = (bottom - top_min - (LK_GRID_ROWS - 1) * LK_GAP) / LK_GRID_ROWS; + if (kh_ > LK_KEY_MAX_H) kh_ = LK_KEY_MAX_H; + if (kh_ < LK_KEY_MIN_H) kh_ = LK_KEY_MIN_H; + + *kw = kw_; + *kh = kh_; + *x0 = (w - (LK_GRID_COLS * kw_ + (LK_GRID_COLS - 1) * LK_GAP)) / 2; + *y0 = bottom - (LK_GRID_ROWS * kh_ + (LK_GRID_ROWS - 1) * LK_GAP); + if (*y0 < top_min) *y0 = top_min; /* 极小屏兜底;480×720 不会触发 */ } -/* 阻塞数字键盘;返回 1=收集到非空文本(关闭键盘即结束/校验)。 - * SDK 现状:键盘「确定」只置内部 submit 标记不关闭,关闭路径(取消/点背景)又把标记清 0, - * 故 meow_ui_text_input 的返回值不可作“确认”判据;文本经 on_change 实时收集,空=取消。 */ -static int lock_read_pin(char *out, int outsz) +static void lock_key_rect(int idx, int *x, int *y, int *w, int *h) { - meow_ime_ctx c; - memset(&c, 0, sizeof c); - c.mode = MEOW_IME_NUM; - c.maxlen = (int)sizeof g_ime_pin; - c.on_change = ime_pin_change; - c.ctx = NULL; + int x0, y0, kw, kh; + lock_keypad_geom(&x0, &y0, &kw, &kh); + int row = idx / LK_GRID_COLS, col = idx % LK_GRID_COLS; + *x = x0 + col * (kw + LK_GAP); + *y = y0 + row * (kh + LK_GAP); + *w = kw; + *h = kh; +} - g_ime_pin[0] = '\0'; +/* ============================================================ */ +/* PIN 状态 */ +/* ============================================================ */ +static void lock_refresh(void) +{ + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +/* 「确认」仅在 ≥4 位时可用;其余情况保持禁用(SDK 按状态机变淡且不响应命中)。 */ +static void lock_sync_confirm(void) +{ + if (g_keys[LK_KOK]) + meow_widget_set_enabled(g_keys[LK_KOK], g_pin_len >= MR_PIN_MIN_LEN); +} + +static void lock_clear_digits(void) +{ + g_pin_len = 0; + g_pin[0] = '\0'; + lock_sync_confirm(); +} + +static void lock_append_digit(int d) +{ + if (g_pin_len >= MR_PIN_MAX_LEN) return; /* 8 位上限:多余按键忽略 */ + g_pin[g_pin_len++] = (char)('0' + d); + g_pin[g_pin_len] = '\0'; g_msg[0] = '\0'; g_msg_err = 0; - g_waiting = 1; /* 锁屏上部提示改为输入说明 */ - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); /* 键盘区背景快照取当前帧 */ - (void)meow_ui_text_input(&c); - g_waiting = 0; - - snprintf(out, (size_t)outsz, "%s", g_ime_pin); - return out[0] != '\0'; + lock_sync_confirm(); + lock_refresh(); } -static void lock_attempt(void) +static void lock_backspace(void) { - char pin[32] = ""; - if (!lock_read_pin(pin, (int)sizeof pin)) { - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); - return; /* 关闭键盘未输入:留在锁屏 */ - } + if (g_pin_len > 0) g_pin[--g_pin_len] = '\0'; + g_msg[0] = '\0'; + g_msg_err = 0; + lock_sync_confirm(); + lock_refresh(); +} - if (!mr_pin_verify(pin)) { - snprintf(g_msg, sizeof g_msg, "PIN 错误,请重试"); +static void lock_clear_all(void) +{ + lock_clear_digits(); + g_msg[0] = '\0'; + g_msg_err = 0; + lock_refresh(); +} + +static void lock_confirm(void) +{ + if (g_pin_len < MR_PIN_MIN_LEN) { + snprintf(g_msg, sizeof g_msg, "请输入至少 %d 位数字", MR_PIN_MIN_LEN); g_msg_err = 1; - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + lock_refresh(); + return; + } + if (!mr_pin_verify(g_pin)) { /* 与设置页同一校验入口(logic/pin) */ + meow_log(MEOW_LOG_INFO, "lock: pin wrong (len=%d), buffer cleared", g_pin_len); + snprintf(g_msg, sizeof g_msg, "PIN 错误,请重试"); + g_msg_err = 1; + lock_clear_digits(); + lock_refresh(); return; } - g_msg[0] = '\0'; g_msg_err = 0; - meow_view_page_pop(); /* 通过 → 回书库(ACT_LIBRARY on_enter) */ + meow_log(MEOW_LOG_INFO, "lock: pin ok"); + meow_view_page_pop(); /* 通过 → 回书库(ACT_LIBRARY on_enter) */ } /* ============================================================ */ @@ -101,33 +180,42 @@ static void lock_draw(meow_view *v, const meow_theme *th, void *ctx) int w = meow_ui_width(); int h = meow_ui_height(); int sb = meow_ui_statusbar_height(); - int cy = sb + (h - sb) / 2; + + int x0, y0, kw, kh; + lock_keypad_geom(&x0, &y0, &kw, &kh); + (void)x0; (void)kw; (void)kh; /* 自绘整页底色:验证前不露任何书库内容(本页是顶层唯一 entered 页)。 */ meow_ui_fill_rect(0, sb, w, h - sb, th->bg); - meow_ui_text_center(w / 2, cy - 150, "图库已锁定", th->font_title, th->onSurface); - if (g_waiting) { - meow_ui_text_center(w / 2, cy - 104, "输入 4-8 位数字后关闭键盘", th->font_body, - th->onSurface); - meow_ui_text_center(w / 2, cy - 76, "空输入 = 取消,回到锁屏", th->font_body, - th->onSurfaceVariant); - } else { - meow_ui_text_center(w / 2, cy - 104, "输入启动 PIN 继续", th->font_body, - th->onSurfaceVariant); - meow_ui_text_center(w / 2, cy - 76, "4-8 位数字 · 系统数字键盘", th->font_body, - th->onSurfaceVariant); - } + int title_y = sb + 36; + meow_ui_text_center(w / 2, title_y, "图库已锁定", th->font_title, th->onSurface); - if (g_msg[0]) { - meow_ui_text_center(w / 2, cy - 26, g_msg, th->font_body, + /* 已输入位数:星号=已输入,点=空位;不回显明文。 */ + char dots[MR_PIN_MAX_LEN * 2 + 1]; + int n = 0; + for (int i = 0; i < MR_PIN_MAX_LEN; i++) { + dots[n++] = (i < g_pin_len) ? '*' : '.'; + if (i + 1 < MR_PIN_MAX_LEN) dots[n++] = ' '; + } + dots[n] = '\0'; + int dots_y = title_y + meow_ui_text_height(th->font_title) + 18; + meow_ui_text_center(w / 2, dots_y, dots, th->font_title, + g_pin_len > 0 ? th->primary : th->onSurfaceVariant); + + int status_y = dots_y + meow_ui_text_height(th->font_title) + 8; + if (g_msg[0]) + meow_ui_text_center(w / 2, status_y, g_msg, th->font_body, g_msg_err ? th->error : th->primary); - } + else + meow_ui_text_center(w / 2, status_y, "输入 4-8 位数字后点确认", th->font_body, + th->onSurfaceVariant); - /* 安全口径(如实告知):PIN 不是安全边界。 */ - meow_ui_text_center(w / 2, h - 86, "PIN 仅用于防止随手查看,", th->font_body, + /* 安全口径(如实告知),紧贴键盘上方。 */ + int note_y = y0 - 54; + meow_ui_text_center(w / 2, note_y, "PIN 仅用于防止随手查看,", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, h - 58, "TF 卡可被直接读取,不构成安全边界。", th->font_body, + meow_ui_text_center(w / 2, note_y + 24, "TF 卡可被直接读取,不构成安全边界。", th->font_body, th->onSurfaceVariant); } @@ -135,13 +223,13 @@ static void lock_place(meow_view *v) { int w = meow_ui_width(); int h = meow_ui_height(); - int sb = meow_ui_statusbar_height(); - int cy = sb + (h - sb) / 2; - v->x = 0; v->y = 0; v->w = w; v->h = h; - int bw = 220, bh = 52; - if (g_btn) meow_widget_set_rect(g_btn, (w - bw) / 2, cy + 20, bw, bh); + for (int i = 0; i < LK_KEY_COUNT; i++) { + int x, y, kw, kh; + lock_key_rect(i, &x, &y, &kw, &kh); + if (g_keys[i]) meow_widget_set_rect(g_keys[i], x, y, kw, kh); + } } /* ============================================================ */ @@ -154,12 +242,24 @@ static int lock_on_event(meow_view *v, int ev, int a, int b, void *ctx) /* 左缘返回(以及任何滑动)一律吞掉:锁屏不允许退出 App,也不允许绕过 PIN。 */ if (ev == MR_VEV_SWIPE) return 1; - if (ev == MR_VEV_TAP) { - int x = a, y = b; - if (meow_widget_hit(g_btn, x, y)) lock_attempt(); - return 1; /* 锁屏页消费点击,不向 App 其它页透传 */ + if (ev == MR_VEV_LONG_PRESS) { + /* 长按「退格」= 清空(可选增强;其余长按也只吞掉,不透传其它页)。 */ + if (meow_widget_hit(g_keys[LK_KBACK], a, b)) lock_clear_all(); + return 1; } - return 0; + + if (ev == MR_VEV_TAP) { + for (int i = 0; i < LK_KEY_COUNT; i++) { + if (!g_keys[i] || !meow_widget_hit(g_keys[i], a, b)) continue; + if (i <= LK_K9) lock_append_digit(i + 1); /* 1..9 */ + else if (i == LK_KBACK) lock_backspace(); + else if (i == LK_K0) lock_append_digit(0); + else lock_confirm(); + return 1; + } + return 1; /* 锁屏页消费所有点击(含空白区),不向 App 其它页透传 */ + } + return 0; /* 物理键交 app.c 的 on_key 仅日志(不映射数字/确认) */ } /* ============================================================ */ @@ -171,15 +271,28 @@ static void lock_page_enter(meow_view *v, void *ctx) meow_ui_set_fullscreen(0); meow_ui_set_page_bg(meow_theme_get()->bg); + g_pin_len = 0; + g_pin[0] = '\0'; g_msg[0] = '\0'; g_msg_err = 0; - if (!g_btn) { + static const char *const labels[LK_KEY_COUNT] = { + "1", "2", "3", "4", "5", "6", "7", "8", "9", "退格", "0", "确认", + }; + for (int i = 0; i < LK_KEY_COUNT; i++) { + if (g_keys[i]) continue; /* page_push 已先 meow_ui_activity_push(ACT_LOCK) → 控件 owner 正确。 */ - g_btn = meow_btn_create_variant(0, 0, 220, 52, "输入 PIN", MEOW_BTN_FILLED); - if (g_btn) meow_view_add_widget(v, g_btn); + int variant = (i == LK_KOK) ? MEOW_BTN_FILLED + : (i == LK_KBACK) ? MEOW_BTN_OUTLINED + : MEOW_BTN_TONAL; + g_keys[i] = meow_btn_create_variant(0, 0, 96, LK_KEY_MIN_H, labels[i], variant); + if (g_keys[i]) meow_view_add_widget(v, g_keys[i]); } + lock_place(v); + lock_sync_confirm(); + meow_log(MEOW_LOG_INFO, "lock: pad ready (12 keys, screen %dx%d)", + meow_ui_width(), meow_ui_height()); mr_nav_report(); meow_ui_paint_full(); } @@ -193,7 +306,8 @@ static void lock_page_leave(meow_view *v, void *ctx) static void lock_page_destroy(meow_view *v, void *ctx) { (void)v; (void)ctx; - g_btn = NULL; /* 控件随 meow_view_free 释放(v->widgets) */ + for (int i = 0; i < LK_KEY_COUNT; i++) g_keys[i] = NULL; + /* 控件随 meow_view_free 释放(v->widgets) */ } meow_view *lock_view_create(void) From 87f4ad0a82345667bad046854ba5d7ae1ad4e3dc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 11:02:43 +0800 Subject: [PATCH 30/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=94=81=E5=B1=8F?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E9=80=80=E6=A0=BC=E5=9C=A8=E7=9C=9F=E6=9C=BA?= =?UTF-8?q?=E5=8F=AF=E8=BE=BE=EF=BC=88=E4=BB=85=E5=9C=A8=E9=94=81=E5=B1=8F?= =?UTF-8?q?=E9=A1=B5=E5=90=AF=E7=94=A8=E9=95=BF=E6=8C=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M29 评审 P2-1:SDK 长按是 opt-in,全仓没有运行时调用 meow_ui_set_long_press_cb,meow_input_long_press_poll 恒返回 0,真机上 MEOW_EV_LONG_PRESS 永不产生——「长按退格清空」只表现为删一位。 - app.c 注册 no-op 长按回调,但只在 ACT_LOCK 为顶层页时启用(进主循环前对齐 一次 + on_tick 每拍对齐),解锁 pop 后置 NULL。全程开启会让长按触发后的 抬手不再产生 TAP/SWIPE,吞掉书库/阅读器里「按住 >=500ms」的点击。 - test_settings_lock_host:长按用例改走真实 SDK 路径——未注册 poll==0(P2 根因)、注册后按住 700ms 产出 LONG_PRESS 且坐标=退格键心、被锁屏 view 消费、 抬手不补发 TAP、解锁后关掉再 poll==0。 - lock.c 头注释补上该 opt-in 依赖,避免以后只看锁屏页时误判可达。 --- tuku/app.c | 25 ++++++++++++++++ tuku/test/test_settings_lock_host.c | 45 +++++++++++++++++++++++------ tuku/ui/lock/lock.c | 4 ++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/tuku/app.c b/tuku/app.c index 1e56c25..9be1600 100644 --- a/tuku/app.c +++ b/tuku/app.c @@ -81,8 +81,32 @@ static void app_on_swipe(int dir, int dx, int dy) if (app_active() == ACT_LIBRARY) library_on_swipe(dir, dx); } +/* 通用长按(SDK §6.3)是 opt-in:未注册回调时 meow_input_long_press_poll 直接返回 0, + * MEOW_EV_LONG_PRESS 永远不会产生 —— 锁屏的「长按退格清空」在真机上就退化成单删一位。 + * 这里注册一个 no-op 回调把长按打开;事件仍然先经 meow_view_dispatch_event 路由,锁屏页 + * 自己消费 LONG_PRESS(其它页命中不到就走 no-op)。 + * + * 只在锁屏为顶层页时启用:长按一旦触发,其抬手不再产生 TAP/SWIPE(SDK 抑制点击), + * 全程开启会让书库/阅读器里「按住稍久」的点击被吞掉。锁屏页解锁 pop 后由 app_on_tick + * 下一拍关掉,避免把长按语义泄漏到其它页。 */ +static int g_long_press_on; + +static void app_on_long_press(int x, int y) +{ + (void)x; (void)y; /* 锁屏已在 view 分发里消费;其余页没有长按语义 */ +} + +static void app_sync_long_press(void) +{ + int on = (app_active() == ACT_LOCK); + if (on == g_long_press_on) return; + meow_ui_set_long_press_cb(on ? app_on_long_press : NULL); + g_long_press_on = on; +} + static void app_on_tick(void) { + app_sync_long_press(); library_on_tick(); } @@ -126,6 +150,7 @@ int main(void) meow_view *lock = lock_view_create(); meow_view_page_push(lock_view_should_show() ? ACT_LOCK : ACT_LIBRARY); + app_sync_long_press(); /* 进主循环前先对齐一次:启动即锁屏时首帧长按就可用 */ meow_ui_events ev = { app_on_key, app_on_tap, app_on_swipe, app_on_tick, diff --git a/tuku/test/test_settings_lock_host.c b/tuku/test/test_settings_lock_host.c index 93cebd7..32be336 100644 --- a/tuku/test/test_settings_lock_host.c +++ b/tuku/test/test_settings_lock_host.c @@ -11,7 +11,8 @@ * 主题模式切换后整帧重绘不崩,以及两条端到端路径: * - **锁屏页内自绘 12 键数字键盘**(1-9 / 退格·0·确认):错 PIN 留在锁屏 → 对 PIN * page_pop 回书库;位数不足确认不可用、超长输入截断、退格/长按清空、错误后清空重输; - * 键高/间距/屏底 28px 热区几何契约; + * 键高/间距/屏底 28px 热区几何契约;长按退格走真实 SDK opt-in 路径(未注册回调不产生 + * MEOW_EV_LONG_PRESS = M29 评审 P2 的根因,注册后才清空); * - 设置页两遍输入开启 PIN(仍走真实 MEOW_IME_NUM 键盘注入)。 * * 编译:见 test/run_tests.sh —— 它把本测试、settings/lock/pin 源码与 SDK 源码 @@ -124,12 +125,8 @@ static void lock_type(meow_view *lock, const char *digits) for (const char *s = digits; *s; s++) lock_tap_key(lock, lock_key_idx(*s)); } -static void lock_long_back(meow_view *lock) -{ - int cx, cy; - if (lock_key_center(lock, LK_KBACK, &cx, &cy)) - meow_view_dispatch_event(MEOW_EV_LONG_PRESS, cx, cy); -} +/* app.c 注册的 no-op 长按回调(事件先经 view 分发,锁屏页消费;其它页落到这里)。 */ +static void app_long_press_noop(int x, int y) { (void)x; (void)y; } static void settings_tap_pin_row(void){ /* 布局:sb + 56 顶栏 + 6 + 段头34 + 方向行64 + 12 + 段头34 → PIN 列表首行中心 +32 */ @@ -287,16 +284,46 @@ int main(void) lock_tap_key(lock, LK_KOK); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "backspace on empty buffer is a no-op"); - /* 长按退格清空:先输 3 位 → 长按清空 → 再输 4 位正确 PIN 才解锁。 */ + /* 长按退格清空(M29 评审 P2-1)走真实 SDK 路径: + * SDK 长按是 opt-in —— 未注册回调时 meow_input_long_press_poll 恒返回 0, + * MEOW_EV_LONG_PRESS 永远不会产生(真机上「长按清空」只会退化成单删一位,P2 的根因); + * app.c 在锁屏为顶层页时注册 no-op 回调后,按住 700ms(≥500 阈值)应真正产出 LONG_PRESS, + * 并按主循环顺序先交 view 分发(锁屏页消费),且其抬手不再补一个 TAP。 */ meow_view_page_push(ACT_LOCK); + int bx = 0, by = 0; + CHECK(lock_key_center(lock, LK_KBACK, &bx, &by), "backspace key has a rect"); + + meow_ui_set_long_press_cb(NULL); + meow_input_touch_set(1, bx, by, 700); + CHECK(meow_input_long_press_poll() == 0, + "no LONG_PRESS while callback unregistered (opt-in gate = P2 root cause)"); + meow_input_touch_release(); + meow_input_drain(); /* 未成长按的这次按压,抬手会补一个 TAP,清掉 */ + lock_type(lock, "135"); - lock_long_back(lock); + meow_ui_set_long_press_cb(app_long_press_noop); /* 同 app.c:锁屏为顶层页时注册 */ + meow_input_touch_set(1, bx, by, 700); + CHECK(meow_input_long_press_poll() == 1, "registered long-press crosses the 500ms threshold"); + meow_input_ev lp = { 0, 0, 0 }; + CHECK(meow_input_pop(&lp) == 1 && lp.type == MEOW_EV_LONG_PRESS && + lp.a == bx && lp.b == by, "LONG_PRESS queued at the pressed backspace key"); + CHECK(meow_view_dispatch_event(MEOW_EV_LONG_PRESS, lp.a, lp.b) == 1, + "lock view consumes LONG_PRESS before the no-op callback"); + meow_input_touch_release(); + CHECK(meow_input_pop(&lp) == 0, "long-press release does not emit a stray TAP"); lock_tap_key(lock, LK_KOK); CHECK(meow_ui_activity_active() == ACT_LOCK, "long-press backspace cleared input"); lock_type(lock, "1357"); lock_tap_key(lock, LK_KOK); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "unlock after long-press clear"); + /* 解锁后 app.c 关掉长按:不得把长按语义泄漏到书库/阅读器(按住稍久的点击仍算 TAP)。 */ + meow_ui_set_long_press_cb(NULL); + meow_input_touch_set(1, bx, by, 700); + CHECK(meow_input_long_press_poll() == 0, "long-press off again after leaving the lock page"); + meow_input_touch_release(); + meow_input_drain(); + /* ---- 设置页两遍输入开启 PIN(MEOW_IME_NUM) ---- */ mr_pin_disable(); meow_view_page_push(ACT_SETTINGS); diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c index 542d6ff..159d198 100644 --- a/tuku/ui/lock/lock.c +++ b/tuku/ui/lock/lock.c @@ -8,7 +8,9 @@ * 「点好几下才弹键盘」;页内键盘完全离线、无 IME 会话,首次点按必响应; * - 4-8 位数字(logic/pin 的 MR_PIN_MIN_LEN/MAX_LEN),顶部只显示星号/圆点数位,不回显明文; * 满 4 位后「确认」才可用;错误 → 清空 + 提示;通过 → page_pop 回书库; - * - 长按「退格」清空已输入(不做惩罚性计数/锁定); + * - 长按「退格」清空已输入(不做惩罚性计数/锁定);SDK 长按是 opt-in,需 app.c 在锁屏 + * 为顶层页时注册 meow_ui_set_long_press_cb(事件仍先走本页 view 分发),否则真机上 + * MEOW_EV_LONG_PRESS 不会产生、长按只会退化成单删一位; * - 左缘返回**必须**由本页 on_event 消费 SWIPE(meow_view_dispatch_event 先于 app.c 的 * on_swipe;否则 app.c 对非 library 页无条件 page_pop,PIN 会被绕过);TAP/LONG_PRESS * 也一律消费,物理键不接(返回 0,交 app.c 的 on_key 仅日志);屏底 28px 上滑热区 From ffc41bed9c157dced43f6cc7a937026cf302237a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 11:50:22 +0800 Subject: [PATCH 31/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=94=81=E5=B1=8F?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E7=94=A8=E4=BE=8B=E5=8E=BB=E6=8E=89=E6=81=92?= =?UTF-8?q?=E7=9C=9F=E6=96=AD=E8=A8=80=EF=BC=88M30=20=E8=AF=84=E5=AE=A1=20?= =?UTF-8?q?P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「长按清空后仍停在 ACT_LOCK」这条断言测不出清空:进入该段前只输了 "135" (3 位),而确认在 <4 位时本就 disabled,即使长按没清空也照样停在锁屏 → 断言恒真,与措辞不符。 删掉该断言与其前的空点确认;清空的真正判据仍是紧接着的「输 1357 → 确认 → 解锁」:若没清空,缓冲会是 "135"+"1357" = 7 位错码,必 FAIL。已用变异验证: 临时去掉 lock_clear_all() 后该行确实 FAIL,恢复后 PASS。 --- tuku/test/test_settings_lock_host.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tuku/test/test_settings_lock_host.c b/tuku/test/test_settings_lock_host.c index 32be336..2dc0081 100644 --- a/tuku/test/test_settings_lock_host.c +++ b/tuku/test/test_settings_lock_host.c @@ -311,11 +311,13 @@ int main(void) "lock view consumes LONG_PRESS before the no-op callback"); meow_input_touch_release(); CHECK(meow_input_pop(&lp) == 0, "long-press release does not emit a stray TAP"); - lock_tap_key(lock, LK_KOK); - CHECK(meow_ui_activity_active() == ACT_LOCK, "long-press backspace cleared input"); + /* 「到底清没清空」不在这里断言:此刻缓冲只有 "135"(3 位),而确认在 <4 位时本就 + * disabled,任何「仍停在 ACT_LOCK」的判断都恒真、测不出清空(M30 评审 P2-1)。 + * 真正的判据是下一段:若没清空,缓冲会变成 "135"+"1357" = 7 位错码 → 校验失败。 */ lock_type(lock, "1357"); lock_tap_key(lock, LK_KOK); - CHECK(meow_ui_activity_active() == ACT_LIBRARY, "unlock after long-press clear"); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, + "unlock after long-press clear (7-digit buffer would fail if not cleared)"); /* 解锁后 app.c 关掉长按:不得把长按语义泄漏到书库/阅读器(按住稍久的点击仍算 TAP)。 */ meow_ui_set_long_press_cb(NULL); From 62826e1d136bb2af753b6e209051ff21aae4d68b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 13:10:01 +0800 Subject: [PATCH 32/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=99=A8=E4=B8=A4=E7=BA=A7=E6=B8=85=E6=99=B0=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E5=85=88=E4=BD=8E=E6=A1=A3=E4=B8=8A=E5=B1=8F=E3=80=81=E5=81=9C?= =?UTF-8?q?=E7=A8=B3=E5=86=8D=E6=8D=A2=E6=B8=85=E6=99=B0=EF=BC=88M31?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 1280x2275 单页解码 392ms,首次看某页有一瞬加载感。改成: - 大页(JPEG ≥1MP)先按显示长边一半的档位(真机页=step 4)解一版低档, 精确落到显示框后立刻上屏(换图仍是单次原生 blit,不闪白、不重置缩放/滚动); - 停稳 200ms 后才解目标档位(step 2),在**同一槽**原地替换低档,低档随即释放, 常驻不翻倍;高档失败/预算跳过时保留低档,不把整页标成「内存不足」; - 拖动/连翻/条漫滚动/跳页在途期间只解低档;高档请求复用 M22 的 gen/窗口 stale 判据作废(只多一条 sharpen 谓词),出队前/后两条路径都计入取消; - 日志尾部新增 tier=low|high(取消行同),真机可直接核对先糊后清。 分档只给 JPEG:step 是 JPEG 缩解概念,PNG/BMP 后端按目标逐行丢行本来就不慢, 且峰值模型对非 JPEG 按「输出=显示框」建模,多一条小图→落框路径会让模型低估 (不新增解码路径,也不改 sdk/ 与 page_source 的唯一模型)。 宿主回归: - test_reader_membudget B10:JPEG 低档先落位(open 不阻塞)→ 高档落同槽 (槽内字节不变、槽位全程不空)→ 拖动中强塞的高档请求被取消且不被解码; - B8 连翻 100 页(6 类 1.3–12MP 循环)峰值增量 2714KB ≤3MB、常驻 ≤3MB, 低档/高档都确实跑过;B3 断言小页保持单档(不付两次解码); - test_reader_perf P11:非 JPEG 大页不分档,仍是一次原生 blit。 --- tuku/test/test_reader_membudget.c | 121 +++++++++- tuku/test/test_reader_perf.c | 46 +++- tuku/ui/reader/reader.c | 380 ++++++++++++++++++++++++++---- tuku/ui/reader/reader.h | 17 ++ 4 files changed, 513 insertions(+), 51 deletions(-) diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index e83cae6..3765b1c 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -17,7 +17,10 @@ * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 * B7c P1-1 旁路回归:截断 12MP JPEG / 3000×3000 24bpp 头-only BMP(判定 backend, * 后端失败必须 NULL、峰值 ≤3MB)/ 非法位深 PNG(矩阵拒绝→stb 占位) - * B8 M27:连翻 30 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB + * B8 M27/M31:连翻 100 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB; + * 分档页低档/高档都确实跑过(M31:先低后清不把峰值/常驻翻倍) + * B10 M31:两级清晰度生命周期(JPEG 大页):低档先落位(open 不阻塞)→ 停稳后高档落 + * 同一槽(槽内字节不变、槽位不空)→ 拖动中强塞的高档请求被取消通道作废 * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 @@ -591,6 +594,9 @@ int main(void) mr_reader_ui_failed_pages()); /* 取消/预取纪律:100 次翻页的解码次数必须有界(M20 实测曾 1.75× 浪费)。 */ CHECK(decodes <= 100 * 2, "decode count bounded over 100 flips (%d)", decodes); + /* M31:小页(480×640=0.3MP)不分档——低档只会多一次解码 + 落框,纯开销。 */ + CHECK(mr_reader_ui_low_decodes() == 0, + "small pages stay single-tier (%d low decodes)", mr_reader_ui_low_decodes()); meow_view_page_pop(); pump_ms(200); } @@ -762,7 +768,10 @@ int main(void) printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } - /* ---- B8 M27:连翻 30 页(6 类典型页循环)——不掉页、不占位、峰值 ≤3MB ---- */ + /* ---- B8 M27/M31:连翻 100 页(6 类典型页循环)——不掉页、不占位、峰值不增长 ---- + * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档),所以这条同时是 + * 「分档不把峰值/常驻翻倍」的回归:低档与高档在同一槽原地替换,任何时刻的存活增量 + * 仍按单页解码峰值封顶。 */ if (have_jpeg && mixed_pages == 30) { mr_reader_ui_test_set_mem(4096, 16000); mr_reader_open_dir(mixed); @@ -772,11 +781,12 @@ int main(void) int advanced = 0, skipped_max = 0; size_t live_steady = g_live; int d0 = mr_reader_ui_decode_count(); + int low0 = mr_reader_ui_low_decodes(), high0 = mr_reader_ui_high_decodes(); /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 - * 「分配新图 + 解码」的真实峰值;指标 = 整个 30 翻窗口内相对稳态基线的最大存活增量。 + * 「分配新图 + 解码」的真实峰值;指标 = 整个 100 翻窗口内相对稳态基线的最大存活增量。 * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ size_t base = peak_reset(); - for (int i = 0; i < 30; i++) { + for (int i = 0; i < 100; i++) { int before = mr_reader_ui_page(); tap(i % 2 == 0 ? 40 : w - 40, 300); pump_ms(220); @@ -785,20 +795,117 @@ int main(void) skipped_max = mr_reader_ui_skipped_pages(); } size_t transient = g_peak > base ? g_peak - base : 0; - INFO("30 mixed flips: advanced=%d skipped=%d decodes=%d steady=%zuKB " + INFO("100 mixed flips: advanced=%d skipped=%d decodes=%d (low=%d high=%d) steady=%zuKB " "resident=%ldKB max_live_delta=%zuKB", advanced, skipped_max, mr_reader_ui_decode_count() - d0, + mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0, live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); - CHECK(advanced == 30, "30 页全部翻到 (%d/30)", advanced); + CHECK(advanced == 100, "100 页全部翻到 (%d/100)", advanced); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); + CHECK(mr_reader_ui_low_decodes() > low0 && mr_reader_ui_high_decodes() > high0, + "大页确实走了两级(low +%d, high +%d)", + mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0); + CHECK(mr_reader_ui_resident_kb() <= mr_reader_ui_resident_cap_kb(), + "常驻仍在上限内 (%ldKB ≤ %ldKB)", mr_reader_ui_resident_kb(), + mr_reader_ui_resident_cap_kb()); CHECK(transient <= 3 * 1024 * 1024, - "30 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); + "100 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); meow_view_page_pop(); pump_ms(200); } + /* ---- B10 M31:两级清晰度生命周期(2000×3000 JPEG,真机同类大页)---- + * 1) 冻结停稳窗口 → 只解低档:焦点页 tier=low、常驻=显示框大小(仍是一次原生 blit), + * 主线程不等解码(open 立即返回); + * 2) 放开停稳窗口 → 高档落同一槽:tier=high、槽内字节不变(不翻倍常驻)、槽位全程不空; + * 3) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来。 */ + if (have_jpeg) { + char tierbook[MR_PATH_MAX]; + snprintf(tierbook, sizeof tierbook, "%s/tier", root); + int tier_ok = mkdir_p(tierbook) == 0; + for (int i = 0; tier_ok && i < 2; i++) { + char src[MR_PATH_MAX], dst[MR_PATH_MAX]; + snprintf(src, sizeof src, "%s/base2000.jpg", jpgbook); + snprintf(dst, sizeof dst, "%s/t%d.jpg", tierbook, i + 1); + if (copy_file(src, dst) != 0) tier_ok = 0; + } + if (tier_ok) { + mr_reader_ui_test_set_mem(4096, 16000); + mr_reader_ui_test_set_sharpen_ms(60000); /* 冻结:先验证低档先落位 */ + uint64_t t_open = (uint64_t)meow_ui_time_ms(); + mr_reader_open_dir(tierbook); + uint64_t open_ms = (uint64_t)meow_ui_time_ms() - t_open; + CHECK(mr_reader_ui_count() == 2, "tier book pages=%d", mr_reader_ui_count()); + CHECK(open_ms < 500, "open is non-blocking (%llu ms)", + (unsigned long long)open_ms); + pump_ms(800); + meow_ui_paint_full(); + int page0 = mr_reader_ui_page(); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, + "low tier lands first (tier=%d)", mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_low_decodes() >= 1, "low decode issued (%d)", + mr_reader_ui_low_decodes()); + CHECK(mr_reader_ui_high_decodes() == 0, + "no high decode before the settle window (%d)", mr_reader_ui_high_decodes()); + long low_kb = mr_reader_ui_slot_kb(page0); + CHECK(low_kb > 0, "low tier image resident (%ldKB)", low_kb); + int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); + meow_ui_paint_full(); + CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, + "low tier draws as native blit (blit +%d, scaled +%d)", + mr_reader_ui_blit_draws() - bl, mr_reader_ui_scaled_draws() - sc); + + /* 放开升级:边等边采样,焦点槽任何时刻都不能空(不闪白)。 */ + int high0 = mr_reader_ui_high_decodes(); + long min_slot = low_kb; + mr_reader_ui_test_set_sharpen_ms(0); + for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) { + pump_ms(10); + long k = mr_reader_ui_slot_kb(page0); + if (k > 0 && k < min_slot) min_slot = k; + } + meow_ui_paint_full(); + CHECK(mr_reader_ui_page() == page0, "tier swap keeps the page (%d -> %d)", + page0, mr_reader_ui_page()); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "high tier replaces low in the same slot (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_high_decodes() > high0, + "high decode issued after settle (%d -> %d)", high0, + mr_reader_ui_high_decodes()); + long high_kb = mr_reader_ui_slot_kb(page0); + CHECK(high_kb > 0 && high_kb <= low_kb + 8, + "same-slot swap: one page resident, not two (%ldKB -> %ldKB)", low_kb, + high_kb); + CHECK(min_slot > 0, "no blank frame while swapping tiers (min slot %ldKB)", + min_slot); + CHECK(mr_reader_ui_scaled_draws() == sc, + "high tier still native blit (scaled %d -> %d)", sc, + mr_reader_ui_scaled_draws()); + + /* 拖动中取消高档:拖动期间强塞一个「换清晰」请求,必须被取消通道作废。 */ + int hd0 = mr_reader_ui_high_decodes(), hc0 = mr_reader_ui_high_canceled(); + reader_on_touch_down(240, 400); + reader_on_touch_move(180, 400); /* 进入拖动 → g_sharpen_idx=-1 */ + CHECK(mr_reader_ui_sharpen_idx() == -1, "drag clears sharpen eligibility"); + mr_reader_ui_test_queue_high(page0); + pump_ms(200); + CHECK(mr_reader_ui_high_canceled() > hc0, + "high request canceled while dragging (%d -> %d)", hc0, + mr_reader_ui_high_canceled()); + CHECK(mr_reader_ui_high_decodes() == hd0, + "canceled high request is never decoded (%d -> %d)", hd0, + mr_reader_ui_high_decodes()); + reader_on_tap(240, 400); /* 松手:回弹,不翻页 */ + pump_ms(300); + mr_reader_ui_test_set_sharpen_ms(200); /* 恢复产品默认停稳窗口 */ + meow_view_page_pop(); + pump_ms(200); + } + } + /* ---- B9 M27:回退路径集成 —— CMYK 页占位(可重试)且不崩 ---- */ if (have_fallback) { mr_reader_ui_test_set_mem(4096, 16000); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 5a84afc..9692da2 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -15,7 +15,12 @@ * P5 「先点适高,再切条漫」:scaled 不增长(评审点名的第二入口); * P6 双击放大再双击缩回:缩回后按新几何重解 → scaled 不增长(P2-1); * P7 静止若干帧不产生新解码(无重解抖动/活锁,P2-2); - * P8 驻留页图有字节上界(<= 9MB)。 + * P8 驻留页图有字节上界(<= 9MB); + * P9 800×1280(评审表格另一档):翻页 + 条漫都必须零重采样; + * P10 大屏(1080×1920):fit 目标被字节上限钳住后不得反复重解; + * P11 分档策略(M31):非 JPEG 大页不分档(一次解到目标档、仍是原生 blit); + * 低档先落位→停稳换清晰→拖动取消的完整生命周期在 test_reader_membudget 的 B10 + * 用真实 JPEG 夹具覆盖。 * * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 */ @@ -368,6 +373,45 @@ int main(void) "800x1280 webtoon: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); + /* ---- P11 分档策略(M31):非 JPEG 大页不分档——一次解到目标档,仍是原生 blit ---- + * 低档/高档的完整生命周期(低档先落位→停稳换清晰→拖动取消)在 test_reader_membudget + * 的 B10 用真实 JPEG 夹具覆盖;这里只钉住「分档只给 JPEG」这条策略,防止把 PNG/BMP + * 也拖进未校准的「小图→落框」峰值路径。 */ + { + char book3[MR_PATH_MAX]; + snprintf(book3, sizeof book3, "./mr_perf_books_%ld/v3", (long)getpid()); + int ok3 = mkdir_p(book3) == 0; + for (int i = 0; ok3 && i < 2; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/t%d.bmp", book3, i + 1); + /* 1600×2400 = 3.84MP:量级与真机大页相同,但格式不是 JPEG。 */ + if (write_bmp24(p, 1600, 2400, (unsigned)(i + 41)) != 0) ok3 = 0; + } + if (!ok3) { + printf(" SKIP: tier fixture write failed\n"); + } else { + meow_view_page_pop(); /* 关掉 book2 */ + uint64_t t_open = meow_ui_time_ms(); + mr_reader_open_dir(book3); + uint64_t open_ms = meow_ui_time_ms() - t_open; + CHECK(mr_reader_ui_count() == 2, "tier book pages=%d", mr_reader_ui_count()); + CHECK(open_ms < 500, "open returns without decoding on the UI thread (%llu ms)", + (unsigned long long)open_ms); + pump_ms(600); + draw_once(); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "non-JPEG big page decodes straight to the target tier (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_low_decodes() == 0, + "no low tier for PNG/BMP (%d low decodes)", mr_reader_ui_low_decodes()); + int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, + "big BMP page draws as native blit (blit +%d, scaled +%d)", + mr_reader_ui_blit_draws() - bl, mr_reader_ui_scaled_draws() - sc); + } + } + meow_view_page_pop(); meow_view_free(reader); meow_view_free(lib); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index ded7039..d739ef7 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -24,6 +24,10 @@ * 按 stb 真实分配模型算峰值,超 3MB 占位 / MemAvailable <6MB 占位。 * 占位可重试,不是永久失败。余量确实充足(MemFree 富余)时允许放宽到 12MB。 * 页数增长不会线性吃内存(常驻只与解码窗大小有关)。 + * 手感(M31):JPEG 大页(≥1MP)先按「显示长边一半」的档位解一版低档并立刻上屏,停稳 + * MR_SHARPEN_MS 后才解目标档位、在同一槽原地替换(两档都落满显示框 → 换图仍是单次 blit, + * 不闪白、不重置缩放/滚动)。拖动/连翻/条漫滚动/跳页在途期间只解低档;高档请求复用 M22 的 + * gen/窗口取消通道作废。每页解码日志尾部带 tier=low|high 便于真机核对「先糊后清」。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page @@ -31,7 +35,7 @@ * 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。 * * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; - * 2.5s 超时则降级落位并提示)。 + * 目标页的低档落位即可跳过去,停稳后原地换清晰;2.5s 超时则降级落位并提示)。 * * 册间衔接:末页前进 → 同父目录下一个含图册(书库自然序)的首页;首页后退 → 上一册末页; * 无相邻册时停住并轻提示(不误触退出)。条漫在条顶/条底继续拉 MR_BOOK_PULL 像素松手触发。 @@ -85,6 +89,19 @@ #define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */ #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ +/* ---- 两级清晰度(M31):先低档秒出,停稳再换高档 ---- + * 大页(真机 1280×2275 单页解码 392ms)打开/翻到时先按「显示长边的一半」解一版低档, + * 落框后立刻上屏;停稳 MR_SHARPEN_MS 后再解目标档位替换同一槽。低档与高档都**精确落到 + * 显示框**,所以换图那一下尺寸不变:仍是一次原生 blit,不闪白、不重置缩放/滚动。 + * 内存:同页只占一个槽(高档落地时替换低档),低档的额外峰值只是「小图 + 一次落框重采样」, + * 已由 page_source 的峰值模型计入(step>1 且输出≠显示框时的 +box)。 */ +#define MR_SHARPEN_MS 200 /* 停稳多久后才发起高档解码(150–300ms 区间取中) */ +#define MR_TIER_MIN_PIXELS 1000000LL /* 源像素少于此值不分档:解本身不慢,分档只添乱 */ +#define MR_LOW_MIN_LONG 240 /* 低档输出长边下限(再粗就糊到难看) */ +#define MR_LOW_MAX_LONG 480 /* 低档输出长边上限(再细省不了多少解码) */ + +/* MR_TIER_LOW / MR_TIER_HIGH 定义在 ui/reader/reader.h(宿主测试也要用同一套档位值)。 */ + #define MR_REQ_MAX 8 #define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */ @@ -152,6 +169,9 @@ typedef struct page_slot { meow_img *img; /* 已 downscale 的页图 */ int src_w, src_h; int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */ + unsigned char tier; /* MR_TIER_*:低档过渡图 / 目标档位(M31) */ + unsigned char hi_tried; /* 1=这一槽最近一次「换清晰」失败/跳过(别每 tick 重试; + * 拖动取消时由 settle_reset 复位,停稳后还能再试) */ } page_slot; static page_slot g_slot[MR_SLOTS]; @@ -176,10 +196,12 @@ static page_meta *g_meta; typedef struct decode_plan { int want_w, want_h; int box_w, box_h; + int tier; /* MR_TIER_LOW / MR_TIER_HIGH(M31) */ } decode_plan; static decode_plan g_plan; static int g_plan_page; /* 本次解码的页号(只用于日志) */ static int g_decode_skip; /* reader_load_page 的跳过原因(MR_SKIP_*) */ +static int g_decode_tier; /* reader_load_page 实际生效的档位(低档降为单档时=HIGH) */ static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */ #define MR_SKIP_NONE 0 /* 解出来了 */ @@ -238,6 +260,8 @@ typedef struct page_req { unsigned gen; int want_w, want_h; /* 按哪个显示框解码(主线程算,slot_ready 判据) */ int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */ + unsigned char tier; /* MR_TIER_*(M31:低档过渡 / 目标档位) */ + unsigned char sharpen; /* 1=「停稳后换清晰」的追加请求:动起来就作废 */ } page_req; typedef struct page_res { int idx; @@ -246,6 +270,7 @@ typedef struct page_res { int fail; int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; + unsigned char tier; /* MR_TIER_*:结果按哪一档解出来的 */ } page_res; static pthread_t g_worker; @@ -259,6 +284,19 @@ static int g_req_head, g_req_tail, g_req_n; static page_res g_res[MR_RES_MAX]; static int g_res_n; +/* ---- 两级清晰度状态(M31) ---- + * g_sharpen_idx:当前唯一允许解高档的页(-1=拖动/未停稳/无需升级)。worker 在锁内据此 + * 作废「在飞的高档请求」——复用 M22 已有的 gen/窗口取消机制,只是多一条判据。 + * g_move_ms:最近一次「动」的时刻(按下/拖动/吸附动画/条漫滚动/fling/换页); + * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 */ +static int g_sharpen_idx = -1; +static int g_hi_inflight = -1; /* 已排队/正在解的目标档位页(去重用;锁内读写) */ +static uint64_t g_move_ms; +static int g_sharpen_ms = MR_SHARPEN_MS; +static volatile int g_low_decodes; /* 完成的低档解码次数(宿主测试/日志对照) */ +static volatile int g_high_decodes; /* 完成的目标档位解码次数 */ +static volatile int g_high_canceled; /* 被取消的高档请求数(拖动/换页/停稳失效) */ + /* ---- 节拍/动画 ---- */ static meow_anim *g_ticker; static meow_anim *g_snap; /* 翻页滑动吸附 */ @@ -420,15 +458,64 @@ static void plan_box_size(int want_w, int want_h, int *bw, int *bh) /* 固定字段的解码日志:page/src/peak/path 之后是结论,便于 `grep 'reader decode'` * 后按列看曲线。peak = reader_estimate_peak 的预估峰值(字节/kB);path = 实际解码路径 - * (backend = M23 真缩解后端,stb = 回退旧路径)。 */ + * (backend = M23 真缩解后端,stb = 回退旧路径);tier = 本次请求的清晰度档位(M31)。 */ static void log_decode(int page, int src_w, int src_h, long long peak_bytes, long memfree, long memavail, const char *verdict, int step, - int backend, unsigned long long ms) + int backend, int tier, unsigned long long ms) { meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu", + "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu tier=%s", page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, - backend ? "backend" : "stb", step, ms); + backend ? "backend" : "stb", step, ms, + tier == MR_TIER_LOW ? "low" : "high"); +} + +/* 低档解码目标长边(M31):显示长边的一半,夹到 [MR_LOW_MIN_LONG, MR_LOW_MAX_LONG]。 + * 只对 JPEG 分档,且必须真的下探一档以上(后端 DCT 阶 n = ceil(8×目标/源长边); + * 低档 n 不更小 = 同样的活,只多一次落框重采样): + * - 「档位」(step)本来就是 JPEG 缩解的概念;PNG/BMP 后端按目标逐行丢行,本来就不慢, + * 而且阅读器的单页峰值模型对非 JPEG 按「输出=显示框」建模——再加一条小图→落框的 + * 路径会让模型低估。分档只留给真机 168–400ms 的 JPEG 大页。 + * 返回 0 = 这页不分档,直接按目标档位解。 */ +static int plan_low_dim(const mr_img_info *in, int box_w, int box_h) +{ + if (!in || in->w <= 0 || in->h <= 0) return 0; + if (in->codec != MR_IMG_CODEC_JPEG) return 0; + if ((long long)in->w * in->h < MR_TIER_MIN_PIXELS) return 0; + int l0 = in->w > in->h ? in->w : in->h; + int bl = box_w > box_h ? box_w : box_h; + if (l0 <= 0 || bl <= 0) return 0; + int lo = bl / 2; + if (lo < MR_LOW_MIN_LONG) lo = MR_LOW_MIN_LONG; + if (lo > MR_LOW_MAX_LONG) lo = MR_LOW_MAX_LONG; + if (lo >= bl) return 0; /* 显示框本来就不大:没有更粗的余地 */ + int nh = (int)((8LL * bl + l0 - 1) / l0); + int nl = (int)((8LL * lo + l0 - 1) / l0); + if (nh < 1) nh = 1; + if (nl < 1) nl = 1; + if (nl >= nh) return 0; /* 同一 DCT 档:低档并不更快 */ + return lo; +} + +/* 按「解码目标长边」跑一次 page_source 的唯一峰值模型(含超预算时继续加粗档位的旧逻辑)。 + * JPEG 档位由目标长边反推,与 meow_img_load_file_step_ex 的 eff 口径一致;模型在 + * 「step>1 且输出≠显示框」时会把落框临时图算进峰值——低档走的正是这条已校准的路径。 */ +static void estimate_for(const mr_img_info *in, int load_dim, int box_w, int box_h, + long mfree, long mavail, mr_decode_decision *dec) +{ + int step = 1; + if (in->codec == MR_IMG_CODEC_JPEG) { + int l0 = in->w > in->h ? in->w : in->h; + step = meow_img_pick_scale(l0, load_dim); + } + for (;;) { + reader_estimate_peak(in, step, box_w, box_h, mfree, mavail, dec); + /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ + if (dec->path == MR_DECODE_PATH_BACKEND || dec->skip == MR_DECODE_OK || + step >= 8 || in->codec != MR_IMG_CODEC_JPEG) + break; + step *= 2; + } } static meow_img *reader_load_page(const char *path, void *ud, int maxdim) @@ -438,8 +525,8 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) uint64_t t0 = meow_ui_time_ms(); if (!path || !path[0]) return NULL; - /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限);worker/主线程同步兜底 - * 路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ + /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限 + 档位);worker/主线程 + * 同步兜底路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ int want_w = g_plan.want_w, want_h = g_plan.want_h; int box_w = g_plan.box_w, box_h = g_plan.box_h; int page = g_plan_page; @@ -460,30 +547,38 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) * reader_estimate_peak 同时给出「走后端还是 stb」与「是否占位」。后端能接住的页 * (M23,峰值与源尺寸无关)直接放行;只有回退 stb 的页才参与峰值预算/MemAvailable * 门控。模型不再在本文件另写一份。 */ + int tier = g_plan.tier; mr_img_info in; int have = mr_probe_info(path, &in); + int load_dim = dim; /* 本次解码的输出长边(低档 < dim) */ mr_decode_decision dec; memset(&dec, 0, sizeof dec); dec.step = 1; + g_decode_tier = MR_TIER_HIGH; + if (!have) tier = MR_TIER_HIGH; /* 尺寸未知:没有分档依据,按单档解(日志别谎报) */ if (have) { - int step = 1; - if (in.codec == MR_IMG_CODEC_JPEG) { - int l0 = in.w > in.h ? in.w : in.h; - step = meow_img_pick_scale(l0, dim); + if (tier == MR_TIER_LOW) { + int lo = plan_low_dim(&in, box_w, box_h); + if (lo > 0 && lo < dim) load_dim = lo; + else tier = MR_TIER_HIGH; /* 这页不值得/无法再粗 → 单档 */ } - for (;;) { - reader_estimate_peak(&in, step, box_w, box_h, mfree, mavail, &dec); - /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ - if (dec.path == MR_DECODE_PATH_BACKEND || dec.skip == MR_DECODE_OK || - step >= 8 || in.codec != MR_IMG_CODEC_JPEG) - break; - step *= 2; + estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); + /* 低档多一次落框重采样,峰值比高档高;门控拒绝时退回单档(高档),别把本来 + * 能显示的页变成「内存不足」占位。非 JPEG 的 stb 回退没有缩解能力(整幅解后 + * 再缩),低档只是白解一遍,同样退回单档。 */ + if (tier == MR_TIER_LOW && + (dec.skip != MR_DECODE_OK || + (dec.path == MR_DECODE_PATH_STB && in.codec != MR_IMG_CODEC_JPEG))) { + tier = MR_TIER_HIGH; + load_dim = dim; + estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); } + g_decode_tier = tier; /* 实际生效的档位(worker 据此计数/落槽) */ if (dec.skip != MR_DECODE_OK) { g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, dec.skip == MR_DECODE_SKIP_LOWMEM ? "skip=lowmem" : "skip=peak", - dec.step, dec.path == MR_DECODE_PATH_BACKEND, + dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -495,12 +590,12 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) * 18–36MB)。只有判定为 stb 的页才允许 stb。 */ int allow_stb = !have || dec.path != MR_DECODE_PATH_BACKEND; int used_backend = 0; - meow_img *im = have ? meow_img_load_file_step_ex(path, dim, dec.step, allow_stb, + meow_img *im = have ? meow_img_load_file_step_ex(path, load_dim, dec.step, allow_stb, &used_backend) : meow_img_load_file_dim(path, dim); if (!im) { log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail", - dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, + dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, tier, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -509,17 +604,18 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } } - /* 3) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit, - * 不会把内存换来的降级变成每帧重采样(M17 的热路径纪律)。 + /* 3) 精确落到显示框(低档过渡图 / 故意用了粗档位时):结果 = box → 命中页仍是一次原生 + * blit,不会把「先糊后清」变成每帧重采样(M17 的热路径纪律)。低档也落满显示框, + * 所以换高档时尺寸不变:不闪白、不重置缩放/滚动,且两档在槽里是同一张的位置。 * 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节, * 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */ - if (have && dec.step > 1 && (in.w >= box_w || in.h >= box_h) && + if (have && (tier == MR_TIER_LOW || dec.step > 1) && (in.w >= box_w || in.h >= box_h) && (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { meow_img *fit = meow_img_resample(im, box_w, box_h); if (fit) { meow_img_free(im); im = fit; } } log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "ok", - dec.step, used_backend, + dec.step, used_backend, tier, (unsigned long long)(meow_ui_time_ms() - t0)); return im; } @@ -680,6 +776,8 @@ static void slot_clear(int i) g_slot[i].idx = -1; g_slot[i].src_w = g_slot[i].src_h = 0; g_slot[i].want_w = g_slot[i].want_h = 0; + g_slot[i].tier = MR_TIER_HIGH; + g_slot[i].hi_tried = 0; } static void slots_free_all(void) @@ -732,9 +830,11 @@ static void slots_reserve(long need_bytes) } } -/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。 - * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */ -static int slot_put(int idx, meow_img *img, int want_w, int want_h) +/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸;低档→高档也走这里)。 + * 返回 1=已放入。want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 + * tier 记下这一槽的清晰度:低档图先顶上,高档图落地时**原地替换**(同一槽、同一尺寸), + * 页面不闪白、不重置缩放/滚动,常驻也不会一页两张。 */ +static int slot_put(int idx, meow_img *img, int want_w, int want_h, int tier) { if (!img || idx < 0) return 0; int i = -1, empty = -1; @@ -766,6 +866,7 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h) if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */ } + /* 低档 → 高档替换:低档图在这里释放,不会与高档同时常驻。 */ slot_clear(i); g_slot[i].idx = idx; g_slot[i].img = img; @@ -773,6 +874,7 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h) g_slot[i].src_h = meow_img_h(img); g_slot[i].want_w = want_w; g_slot[i].want_h = want_h; + g_slot[i].tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); return 1; } @@ -845,11 +947,19 @@ static int prefetch_allowed(void) return a >= MR_PREFETCH_MIN_KB; } -static void queue_page(int idx) +/* 大页才分档:真机 1280×2275 / 1280×1032 这类页单次解码 168–400ms;小图本来就快, + * 分两档只会多一次解码 + 落框重采样。 */ +static int page_needs_tier(int idx) +{ + int sw = 0, sh = 0; + if (!page_meta_dims(idx, &sw, &sh)) return 0; + return (long long)sw * sh >= MR_TIER_MIN_PIXELS; +} + +/* 排一个解码请求。tier 决定解低档还是目标档位;sharpen=1 表示「停稳后换清晰」的追加 + * 请求——动起来(拖动/翻页/滚动)就由 gen/窗口/sharpen 三条判据一起作废。 */ +static int queue_page_tier(int idx, int tier, int sharpen) { - if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ - if (slot_ready(idx) || page_blocked(idx)) return; /* 几何在这里(主线程)算好塞进请求:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 */ int want_w = 0, want_h = 0; page_geometry_at(idx, &want_w, &want_h); @@ -858,8 +968,12 @@ static void queue_page(int idx) plan_box_size(want_w, want_h, &box_w, &box_h); /* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */ slots_reserve((long)box_w * box_h * 4); + int queued = 0; pthread_mutex_lock(&g_lock); - if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { + /* 去重:已排队(req_queued)或「换清晰」请求正在 worker 手里(g_hi_inflight)都不再发。 + * 后者必须单列:请求被 worker 取走后就不在队列里了,只看 req_queued 会每 tick 重发一次。 */ + if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX && + !(sharpen && g_hi_inflight == idx)) { page_req *r = &g_req[g_req_tail]; r->idx = idx; r->gen = g_gen; @@ -867,11 +981,35 @@ static void queue_page(int idx) r->want_h = want_h; r->box_w = box_w; r->box_h = box_h; + r->tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); + r->sharpen = (unsigned char)(sharpen ? 1 : 0); g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; + if (sharpen) g_hi_inflight = idx; + queued = 1; pthread_cond_signal(&g_cv); } pthread_mutex_unlock(&g_lock); + return queued; +} + +static void queue_page(int idx) +{ + if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; + /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ + if (page_blocked(idx)) return; + page_slot *s = slot_find(idx); + if (s && slot_ready(idx)) { + /* 已经能显示:只有「低档 → 目标档位」这一种情况还要再解,而且必须是当前焦点页、 + * 已停稳(g_sharpen_idx 由 tier_pump 在去抖窗口后置位)、并且最近一次升级没失败过。 + * hi_tried 只在「解坏了 / 预算跳过」时置位(避免每 tick 重试);拖动取消会复位。 */ + if (s->tier != MR_TIER_LOW || s->hi_tried || g_sharpen_idx != idx || + !page_needs_tier(idx)) + return; + queue_page_tier(idx, MR_TIER_HIGH, 1); + return; + } + queue_page_tier(idx, page_needs_tier(idx) ? MR_TIER_LOW : MR_TIER_HIGH, 0); } static void *reader_worker(void *arg) @@ -885,15 +1023,26 @@ static void *reader_worker(void *arg) req = g_req[g_req_head]; g_req_head = (g_req_head + 1) % MR_REQ_MAX; g_req_n--; - /* 取消(任务 4 / M20 P0-3):出队后、mr_book_open 前二次判过期——旧册(gen 变) - * 或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 1.75× 解码, - * 全是这种在飞不可取消的请求。锁内判,窗口也在锁内写,避免共享状态竞争。 */ + /* 取消(任务 4 / M20 P0-3;M31 扩展):出队后、mr_book_open 前二次判过期——旧册 + * (gen 变)或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 + * 1.75× 解码,全是这种在飞不可取消的请求。锁内判,窗口/sharpen 目标也在锁内写, + * 避免共享状态竞争。 + * M31:拖动/连翻期间只许解低档——「停稳换清晰」的请求一旦动起来就作废(复用同一条 + * stale 判据,不新造取消通道)。 */ int stale = (req.gen != g_gen) || !in_window(req.idx); + int stale_motion = 0; + if (!stale && req.sharpen && req.idx != g_sharpen_idx) { + stale = 1; + stale_motion = 1; + } + if (stale && req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; pthread_mutex_unlock(&g_lock); if (stale) { + if (stale_motion) g_high_canceled++; meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0", - req.idx, mem_free_kb(), mem_avail_kb()); + "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0 tier=%s", + req.idx, mem_free_kb(), mem_avail_kb(), + req.tier == MR_TIER_LOW ? "low" : "high"); continue; } @@ -902,10 +1051,17 @@ static void *reader_worker(void *arg) g_plan.want_h = req.want_h; g_plan.box_w = req.box_w; g_plan.box_h = req.box_h; + g_plan.tier = req.tier; g_plan_page = req.idx; g_decode_skip = MR_SKIP_NONE; + g_decode_tier = MR_TIER_HIGH; meow_img *img = mr_book_open(&g_book, req.idx); g_decode_count++; + /* 档位以解码回调**实际生效**的为准:低档请求遇到非 JPEG/不值得分档的页会降为 + * 单档(reader_load_page 把 g_decode_tier 置回 HIGH),落槽也就不会误标成低档。 */ + int eff_tier = g_decode_tier; + if (eff_tier == MR_TIER_LOW) g_low_decodes++; + else g_high_decodes++; int skip = img ? MR_SKIP_NONE : g_decode_skip; pthread_mutex_lock(&g_lock); @@ -917,9 +1073,12 @@ static void *reader_worker(void *arg) g_res[g_res_n].skip = skip; g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; + g_res[g_res_n].tier = (unsigned char)eff_tier; g_res_n++; img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } + /* 本页的「换清晰」请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ + if (req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); } @@ -948,10 +1107,12 @@ static void worker_stop(void) g_res_n = 0; g_req_n = 0; g_req_head = g_req_tail = 0; + g_hi_inflight = -1; pthread_mutex_unlock(&g_lock); } -/* 丢弃窗口外的排队请求(主线程调用)。 */ +/* 丢弃窗口外的排队请求(主线程调用)。M31:还没出队的「停稳换清晰」请求,一旦动起来 + * (g_sharpen_idx 被清)也在这里作废,不必等 worker 出队才发现。 */ static void queue_prune(void) { pthread_mutex_lock(&g_lock); @@ -960,6 +1121,11 @@ static void queue_prune(void) for (int i = 0; i < g_req_n; i++) { page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; if (r.gen != g_gen || !in_window(r.idx)) continue; + if (r.sharpen && r.idx != g_sharpen_idx) { + if (g_hi_inflight == r.idx) g_hi_inflight = -1; + g_high_canceled++; + continue; + } /* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */ int w = 0, h = 0; page_geometry_at(r.idx, &w, &h); @@ -974,7 +1140,9 @@ static void queue_prune(void) } /* 收取后台结果:只留窗口内新页。返回 1=有图落位。 - * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 */ + * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 + * M31:高档(停稳换清晰)失败/跳过时,低档已经在屏幕上——保留低档继续看,不把整页标成 + * 「内存不足」或坏图;低档自己的失败/跳过才走旧语义(那才是真的没图)。 */ static int results_drain(void) { page_res got[MR_RES_MAX]; @@ -988,20 +1156,25 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } + page_slot *low = got[i].tier == MR_TIER_HIGH ? slot_find(got[i].idx) : NULL; + if (low && low->tier != MR_TIER_LOW) low = NULL; /* 已有目标档:不是「低档兜底」 */ if (got[i].skip) { /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ + if (low) { low->hi_tried = 1; continue; } /* 低档在屏:降级显示,不占位 */ page_mark_skipped(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } if (got[i].fail) { + if (low) { low->hi_tried = 1; continue; } page_mark_failed(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } if (in_window(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h) && - page_visible(got[i].idx)) changed = 1; + if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h, + got[i].tier) && page_visible(got[i].idx)) + changed = 1; page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ } else { meow_img_free(got[i].img); @@ -1046,6 +1219,72 @@ static void window_update(void) } } +/* ============================================================ */ +/* 两级清晰度的停稳去抖(M31) */ +/* ------------------------------------------------------------ + * 规则只有三条: + * 1) 拖动 / 吸附动画 / 条漫滚动(含 fling)期间一律不动高档:g_sharpen_idx=-1, + * 在飞/排队的「停稳换清晰」请求按 M22 的取消通道作废;跳页在途时同样不解高档 + * (单 worker 串行,导航优先); + * 2) 停稳 MR_SHARPEN_MS 后,焦点页若是低档,才允许它解目标档位(g_sharpen_idx=focus); + * 3) 每次「动」都重置停稳计时;换页/换几何也会清掉资格(重新计时)。 + * 高档只发给焦点页:单 worker 串行,先保证用户正看的那页清晰,邻居留低档待翻到再说。 */ +static int motion_active(void) +{ + if (g_dragging) return 1; + if (g_snap || g_bounce) return 1; + if (g_scroll && meow_scroll_active(g_scroll)) return 1; + return 0; +} + +/* 「动」了:重置停稳计时,并取消当前的高档资格(排队/在飞的请求随之作废)。 + * 被取消的这一页如果还是低档,把 hi_tried 复位——停稳后还能再试一次。 */ +static void settle_reset(void) +{ + g_move_ms = meow_ui_time_ms(); + if (g_sharpen_idx < 0) return; + page_slot *s = slot_find(g_sharpen_idx); + if (s && s->tier == MR_TIER_LOW) s->hi_tried = 0; + pthread_mutex_lock(&g_lock); + g_sharpen_idx = -1; + pthread_mutex_unlock(&g_lock); + queue_prune(); +} + +static void tier_pump(uint64_t now) +{ + if (g_count <= 0 || g_quitting) return; + if (motion_active()) { + g_move_ms = now; + if (g_sharpen_idx >= 0) settle_reset(); + return; + } + /* 跳页在途:单 worker 串行,「换清晰」是锦上添花,不能插在跳页目标的低档前面—— + * 否则滑块一拖就是几秒(早已过停稳窗口),老页的高档会把跳页落位往后挤几百 ms。 */ + if (mr_jump_active(&g_jump)) { + if (g_sharpen_idx >= 0) settle_reset(); + return; + } + int f = focus_page(); + if (f < 0) return; + page_slot *s = slot_find(f); + if (!s || s->tier != MR_TIER_LOW || s->hi_tried || !page_needs_tier(f)) { + if (g_sharpen_idx == f) { + pthread_mutex_lock(&g_lock); + g_sharpen_idx = -1; + pthread_mutex_unlock(&g_lock); + } + return; + } + if (now - g_move_ms < (uint64_t)g_sharpen_ms) return; /* 还没停稳 */ + if (g_sharpen_idx != f) { + pthread_mutex_lock(&g_lock); + g_sharpen_idx = f; + pthread_mutex_unlock(&g_lock); + } + queue_page(f); /* 幂等:已排队/在飞/已清晰/已试过都直接返回 */ +} + /* ============================================================ */ /* 条漫排版 + 滚动会话 */ /* ============================================================ */ @@ -1369,6 +1608,7 @@ static void set_fit(int fit) g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; + settle_reset(); /* 几何变了:停稳计时重来,低档先上屏再换清晰 */ window_update(); update_title(); meow_ui_invalidate(0, 0, g_vw, g_vh); @@ -1401,6 +1641,7 @@ static void commit_page(int target) g_page = target; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; + settle_reset(); /* 换页:停稳计时重来(拖动/连翻期间不解高档) */ window_update(); update_title(); slider_sync(); @@ -1535,6 +1776,7 @@ static void jump_land(int page, int timed_out) g_pan_x = g_pan_y = 0; } slider_sync(); + settle_reset(); /* 落位:停稳计时重来 */ window_update(); update_title(); cont_touch(); @@ -1621,6 +1863,7 @@ static void mode_set(int mode, int from_page) g_zoom_pct = 100; g_pan_x = g_pan_y = 0; slider_sync(); + settle_reset(); /* 模式/排版几何变了:停稳计时重来 */ window_update(); update_title(); cont_touch(); @@ -1663,6 +1906,9 @@ static void open_book_reset_state(void) mr_jump_cancel(&g_jump); g_toast[0] = '\0'; g_title_page = -1; + g_sharpen_idx = -1; /* 新册:高档资格清零,停稳计时重来 */ + g_hi_inflight = -1; + g_move_ms = meow_ui_time_ms(); free(g_meta); g_meta = NULL; } @@ -1760,9 +2006,10 @@ static int open_book(const char *dir, int start_page, int mode) g_plan.want_h = wh; g_plan.box_w = bw; g_plan.box_h = bh; + g_plan.tier = MR_TIER_HIGH; /* 没有后台线程:直接解目标档,不分档 */ g_plan_page = g_page; meow_img *first = mr_book_open(&g_book, g_page); - if (first) slot_put(g_page, first, ww, wh); + if (first) slot_put(g_page, first, ww, wh, MR_TIER_HIGH); meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); } slider_build(); @@ -1876,6 +2123,7 @@ void reader_on_touch_down(int x, int y) { if (!g_ctrl.active || g_quitting) return; if (g_count <= 0) return; + settle_reset(); /* 手一按下去就不算停稳:在飞的高档请求作废 */ g_touch_moved = 0; /* 每次按下都是新手势(chrome 抬手也不残留) */ if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ @@ -2055,6 +2303,7 @@ static void tap_double(int x, int y) g_pan_y = (g_vh / 2 - y) * (z - 100) / 100; clamp_pan(); } + settle_reset(); /* 缩放几何变了:重新计时(低档先上屏) */ window_update(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -2157,6 +2406,9 @@ static void reader_tick(void) /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ if (results_drain() && !g_dragging) invalidate_content(); + /* 两级清晰度:停稳去抖(拖动/滚动期间只留低档,停稳后才补高档)。 */ + tier_pump(now); + /* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */ if (g_open_err && g_open_retry_at && now >= g_open_retry_at) { g_open_retry_at = 0; @@ -2312,6 +2564,13 @@ static void reader_state_reset(void) memset(&g_plan, 0, sizeof g_plan); g_plan_page = 0; g_decode_skip = MR_SKIP_NONE; + g_decode_tier = MR_TIER_HIGH; + g_sharpen_idx = -1; + g_hi_inflight = -1; + g_move_ms = meow_ui_time_ms(); + g_low_decodes = 0; + g_high_decodes = 0; + g_high_canceled = 0; } static void reader_page_enter(meow_view *v, void *ctx) @@ -2527,6 +2786,41 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } +/* M31 两级清晰度观察口:焦点页当前档位(-1=无图/0=低档/1=目标档位)、各档解码次数、 + * 被取消的高档请求数、当前允许升级的页(-1=拖动/未停稳/无需升级)。 */ +int mr_reader_ui_focus_tier(void) +{ + page_slot *s = slot_find(focus_page()); + if (!s) return -1; + return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH; +} +int mr_reader_ui_slot_tier(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return -1; + return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH; +} +long mr_reader_ui_slot_kb(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return 0; + return (long)s->src_w * s->src_h * 4 / 1024; +} +int mr_reader_ui_low_decodes(void) { return g_low_decodes; } +int mr_reader_ui_high_decodes(void) { return g_high_decodes; } +int mr_reader_ui_high_canceled(void) { return g_high_canceled; } +int mr_reader_ui_sharpen_idx(void) { return g_sharpen_idx; } +/* 宿主测试改写「停稳」窗口:0=立刻允许高档(同步验证落位),很大=冻结高档(验证低档先落位)。 */ +void mr_reader_ui_test_set_sharpen_ms(int ms) +{ + g_sharpen_ms = ms < 0 ? 0 : ms; +} +/* 宿主测试:绕过停稳判定强行排一个「换清晰」请求,用于验证拖动中会被取消。 */ +void mr_reader_ui_test_queue_high(int idx) +{ + if (idx < 0 || idx >= g_count) return; + queue_page_tier(idx, MR_TIER_HIGH, 1); +} /* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ void mr_reader_test_toggle_mode(void) { diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 3ddca22..960a43b 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -10,6 +10,11 @@ extern "C" { #endif +/* 两级清晰度档位(M31):低档=先上屏的过渡图,高档=停稳后替换的目标档位。 + * 同时供宿主测试断言槽内档位用(mr_reader_ui_slot_tier / mr_reader_ui_focus_tier)。 */ +#define MR_TIER_LOW 0 +#define MR_TIER_HIGH 1 + void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); @@ -51,6 +56,18 @@ long mr_reader_ui_resident_cap_kb(void); /* 常驻上限(kB) */ int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ +/* ---- M31 两级清晰度(先低档秒出,停稳再换档)---- */ +int mr_reader_ui_focus_tier(void); /* 焦点页档位:-1=无图,0=低档过渡图,1=目标档位 */ +int mr_reader_ui_slot_tier(int idx);/* 指定页档位:-1=不在槽内,否则同上 */ +long mr_reader_ui_slot_kb(int idx); /* 指定槽内页图的常驻字节(KB);0=不在槽内 */ +int mr_reader_ui_low_decodes(void); /* 低档解码次数 */ +int mr_reader_ui_high_decodes(void);/* 目标档位解码次数 */ +int mr_reader_ui_high_canceled(void);/* 被取消(拖动/换页/未停稳)的高档请求数 */ +int mr_reader_ui_sharpen_idx(void); /* 当前允许升级的页;-1=拖动/未停稳/无需升级 */ +/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=200ms):0=立刻允许高档,很大=冻结高档。 */ +void mr_reader_ui_test_set_sharpen_ms(int ms); +/* 绕过停稳判定强排一个「换清晰」请求(验证拖动中会被 M22 的取消通道作废)。 */ +void mr_reader_ui_test_queue_high(int idx); int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ /* 直接切模式(绕过顶栏 tap 的命中时序;测试用,产品路径仍走顶栏动作)。 */ From e896a28fbae5ed854df5c612a515a75287c38b20 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 16:15:50 +0800 Subject: [PATCH 33/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=20M31=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P1=EF=BC=88=E4=B8=A4=E6=A1=A3=E5=85=B1?= =?UTF-8?q?=E5=AD=98=E5=B3=B0=E5=80=BC=20+=20=E6=8D=A2=E6=B8=85=E6=99=B0?= =?UTF-8?q?=E6=8C=A4=E6=8E=89=E9=A2=84=E5=8F=96=EF=BC=89=E4=B8=8E=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=20P2=EF=BC=88r2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 内存账没算全:高档解码时低档还占着槽,但门控/预留只按新图算。 - 高档请求携带发起时的常驻快照(page_req.resident),解码前按 「常驻 + reader_estimate_peak()」算这次操作的总峰值,上限 MR_TIER_TOTAL_CAP (12MB,与 page_source 的绝对上限同值;常态 3MB 只约束 stb 回退路径, 后端页本来就超 3MB 且已被 M27 放行),超了跳过高档、保留低档(skip=coexist)。 - 峰值本身靠调度压回基线:分页模式焦点页还是低档时不预取邻居(预取来会被 换清晰腾掉),换清晰前 slots_reserve 腾掉非可见页,高档只与焦点低档共存。 B8(--wrap malloc,两实例并行):max_live_delta 3250KB→550KB, 绝对峰值 5279KB→3932KB(基线 3929KB)。 P1-2 换清晰挤掉预取:换清晰后不重跑窗口,邻居洞留到下次翻页(0/8 命中、4 次/页)。 - 新增每页 in-flight 去重(g_req_inflight):请求被 worker 取走后不再被窗口刷新重排, 这是重复解码/挤掉邻居的根因之一。 - 高档落位后按最近一次翻页方向补回邻居(sharpen_refill);反方向不补(3MB 常驻 上限下放不下,必然再被挤掉)。 - 真机几何探针(480×720,1280×2275 JPEG,8 次 700ms/页):0/8→8/8 命中, 4→1.5 次解码/页。B8 顺序 100 页:100/100 命中、149 次解码(1.49/页)。 P2-1:B8 改成 101 页册顺序前翻 100 次(每页都是不同的下一页),4MP/12MP 夹具 真正成为焦点页;断言最后一页=100、命中率 ≥90%、解码 ≤2/页+余量。 P2-2:B10 去掉恒真的 slot_kb 比较,改断言「总常驻不增加」+「高档操作增量 ≤3MB」+ 「两档共存绝对峰值有界」;补双击放大后换清晰不重置缩放/平移、条漫滚动位置不变。 P2-3:B10 整串生命周期解码次数 ≤12(实测 7);B8 解码次数上限。 夹具用硬链接(同文件系统),避免多页册真拷贝几百 MB。 顺带:MR_SHARPEN_MS 200→150(任务允许 150–300ms,给「换清晰→补邻居」留翻页余量); 新增宿主观察口 flip_hits/misses、zoom/pan/woff(只读)。 --- tuku/test/test_reader_membudget.c | 169 ++++++++++++++++++++++++------ tuku/ui/reader/reader.c | 149 +++++++++++++++++++++----- tuku/ui/reader/reader.h | 8 +- 3 files changed, 269 insertions(+), 57 deletions(-) diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index 3765b1c..32c6e43 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -17,10 +17,12 @@ * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 * B7c P1-1 旁路回归:截断 12MP JPEG / 3000×3000 24bpp 头-only BMP(判定 backend, * 后端失败必须 NULL、峰值 ≤3MB)/ 非法位深 PNG(矩阵拒绝→stb 占位) - * B8 M27/M31:连翻 100 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB; - * 分档页低档/高档都确实跑过(M31:先低后清不把峰值/常驻翻倍) + * B8 M27/M31:顺序连翻 100 页(101 页册,6 类循环,每页都是不同的下一页)不掉页、 + * 不占位、存活增量 ≤3MB、绝对峰值有上界、预取命中 ≥90%、解码次数 ≤2/页+余量 + * (M31 r2:修「每翻一页解 4 次」与高峰值,评审 P1-1/P1-2/P2-1/P2-3) * B10 M31:两级清晰度生命周期(JPEG 大页):低档先落位(open 不阻塞)→ 停稳后高档落 - * 同一槽(槽内字节不变、槽位不空)→ 拖动中强塞的高档请求被取消通道作废 + * 同一槽(总常驻不增加、槽位不空、本次操作增量 ≤3MB)→ 双击放大换清晰不重置缩放/平移 + * → 拖动中强塞的高档请求被取消通道作废 → 整串解码次数有界 * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 @@ -118,6 +120,7 @@ static int g_fail; } while (0) #define INFO(...) do { printf(" info: "); printf(__VA_ARGS__); printf("\n"); } while (0) + /* ------------------------------------------------------------------ */ /* 夹具写入 */ /* ------------------------------------------------------------------ */ @@ -218,6 +221,14 @@ static int copy_file(const char *src, const char *dst) return ok ? 0 : -1; } +/* 同一文件系统优先硬链接:多页册只是「同样的夹具当很多页」,101 页 × 12MP/BMP/PNG 真拷贝 + * 要写几百 MB,跑一次测试 I/O 比解码还久(评审复跑成本)。读侧只按路径打开,硬链接完全等价。 */ +static int link_or_copy(const char *src, const char *dst) +{ + if (link(src, dst) == 0) return 0; + return copy_file(src, dst); +} + static int have_file(const char *path) { struct stat st; @@ -464,20 +475,23 @@ int main(void) } int have_jpeg = gen_jpeg_fixtures(jpgbook); - /* 连翻 30 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 - * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 */ + /* 顺序连翻 100 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 + * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 + * M31 r2(评审 P2-1):101 页顺序遍历(不是 30 页来回跳)——每次翻页都落到**不同的 + * 下一页**,4MP/12MP 夹具都会真正成为焦点页并被解码/换清晰。 */ static const char *const g_mix[] = { "base2000.jpg", "prog2000.jpg", "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", }; const int mix_n = (int)(sizeof g_mix / sizeof g_mix[0]); + const int mixed_goal = 101; /* 100 次前翻 → 第 101 页(不回卷、不触发册间衔接) */ int mixed_pages = 0; if (have_jpeg) { - for (int i = 0; i < 30; i++) { + for (int i = 0; i < mixed_goal; i++) { char src[MR_PATH_MAX], dst[MR_PATH_MAX]; snprintf(src, sizeof src, "%s/%s", jpgbook, g_mix[i % mix_n]); - snprintf(dst, sizeof dst, "%s/p%02d_%s", mixed, i, g_mix[i % mix_n]); - if (copy_file(src, dst) == 0) mixed_pages++; + snprintf(dst, sizeof dst, "%s/p%03d_%s", mixed, i, g_mix[i % mix_n]); + if (link_or_copy(src, dst) == 0) mixed_pages++; } } /* 回退路径集成:CMYK(4 分量)必须占位且不崩。 */ @@ -768,50 +782,65 @@ int main(void) printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } - /* ---- B8 M27/M31:连翻 100 页(6 类典型页循环)——不掉页、不占位、峰值不增长 ---- - * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档),所以这条同时是 - * 「分档不把峰值/常驻翻倍」的回归:低档与高档在同一槽原地替换,任何时刻的存活增量 - * 仍按单页解码峰值封顶。 */ - if (have_jpeg && mixed_pages == 30) { + /* ---- B8 M27/M31:顺序连翻 100 页(101 页册,6 类典型页循环)——不掉页、不占位、 + * 峰值不增长、预取命中、解码次数有界 ---- + * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档)。评审 P1: + * - 顺序前翻(每页都落到不同的下一页),4MP/12MP 夹具真正成为焦点页; + * - 断言语义翻页命中率(目标页已在槽)与解码次数上限,防止「每翻一页解 4 次」; + * - 存活增量仍按单页解码峰值封顶(两档共存时换清晰前会腾掉非可见页,见 reader.c)。 */ + if (have_jpeg && mixed_pages == mixed_goal) { mr_reader_ui_test_set_mem(4096, 16000); mr_reader_open_dir(mixed); pump_ms(1200); - CHECK(mr_reader_ui_count() == 30, "mixed book pages=%d", mr_reader_ui_count()); - int w = meow_ui_width(); + CHECK(mr_reader_ui_count() == mixed_goal, "mixed book pages=%d", mr_reader_ui_count()); int advanced = 0, skipped_max = 0; size_t live_steady = g_live; int d0 = mr_reader_ui_decode_count(); int low0 = mr_reader_ui_low_decodes(), high0 = mr_reader_ui_high_decodes(); + int hits0 = mr_reader_ui_flip_hits(), miss0 = mr_reader_ui_flip_misses(); /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 * 「分配新图 + 解码」的真实峰值;指标 = 整个 100 翻窗口内相对稳态基线的最大存活增量。 * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ size_t base = peak_reset(); for (int i = 0; i < 100; i++) { int before = mr_reader_ui_page(); - tap(i % 2 == 0 ? 40 : w - 40, 300); - pump_ms(220); - if (mr_reader_ui_page() != before) advanced++; + tap(40, 300); /* RTL:左 1/3 = 下一页(顺序前翻) */ + pump_ms(400); /* 700ms/页:给「换清晰→补邻居」留出余量 */ + if (mr_reader_ui_page() == before + 1) advanced++; if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); } size_t transient = g_peak > base ? g_peak - base : 0; - INFO("100 mixed flips: advanced=%d skipped=%d decodes=%d (low=%d high=%d) steady=%zuKB " - "resident=%ldKB max_live_delta=%zuKB", - advanced, skipped_max, mr_reader_ui_decode_count() - d0, + int hits = mr_reader_ui_flip_hits() - hits0; + int miss = mr_reader_ui_flip_misses() - miss0; + int decodes = mr_reader_ui_decode_count() - d0; + INFO("100 mixed flips: advanced=%d last_page=%d hits=%d miss=%d skipped=%d decodes=%d " + "(low=%d high=%d) steady=%zuKB resident=%ldKB max_live_delta=%zuKB abs_peak=%zuKB", + advanced, mr_reader_ui_page(), hits, miss, skipped_max, decodes, mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0, - live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); - CHECK(advanced == 100, "100 页全部翻到 (%d/100)", advanced); + live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024, g_peak / 1024); + CHECK(advanced == 100 && mr_reader_ui_page() == 100, + "100 次顺序前翻逐页推进(advanced=%d last=%d)", advanced, mr_reader_ui_page()); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); CHECK(mr_reader_ui_low_decodes() > low0 && mr_reader_ui_high_decodes() > high0, "大页确实走了两级(low +%d, high +%d)", mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0); + /* 顺序阅读的预取命中率:评审要求不低于基线 7/8。 */ + CHECK(hits >= 90, "顺序翻页预取命中率 ≥90%%(hit=%d miss=%d)", hits, miss); + /* 解码次数上限:分档页 = 低档 + 高档(2 次/页),非 JPEG 单档(1 次/页)+ 开册/补邻居余量。 + * 出现「每页 4 次」的放大(P1-2 的病灶)时这里会立刻失败。 */ + CHECK(decodes <= 100 * 2 + 20, "100 次翻页解码次数 ≤2/页+余量 (decodes=%d)", decodes); CHECK(mr_reader_ui_resident_kb() <= mr_reader_ui_resident_cap_kb(), "常驻仍在上限内 (%ldKB ≤ %ldKB)", mr_reader_ui_resident_kb(), mr_reader_ui_resident_cap_kb()); CHECK(transient <= 3 * 1024 * 1024, "100 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); + /* 两档共存的总账:常驻(≤3MB)+ 单次解码(≤3MB)——不许因为「基线里已含低档」 + * 就看着没涨;绝对存活峰值同样要有上界。 */ + CHECK(g_peak <= (size_t)(mr_reader_ui_resident_cap_kb() + 3 * 1024) * 1024, + "绝对存活峰值 ≤ 常驻上限+3MB (%zuKB)", g_peak / 1024); meow_view_page_pop(); pump_ms(200); } @@ -819,8 +848,11 @@ int main(void) /* ---- B10 M31:两级清晰度生命周期(2000×3000 JPEG,真机同类大页)---- * 1) 冻结停稳窗口 → 只解低档:焦点页 tier=low、常驻=显示框大小(仍是一次原生 blit), * 主线程不等解码(open 立即返回); - * 2) 放开停稳窗口 → 高档落同一槽:tier=high、槽内字节不变(不翻倍常驻)、槽位全程不空; - * 3) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来。 */ + * 2) 放开停稳窗口 → 高档落同一槽:tier=high、**总常驻不增加**(不是恒真的槽内字节比较)、 + * 槽位全程不空、本次操作存活增量 ≤3MB、两档共存绝对峰值有上界; + * 3) 双击放大后按新几何先落低档,再换清晰 → 缩放/平移一字不差(P2-2); + * 4) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来; + * 5) 整串生命周期解码次数有界(P2-3)。 */ if (have_jpeg) { char tierbook[MR_PATH_MAX]; snprintf(tierbook, sizeof tierbook, "%s/tier", root); @@ -829,7 +861,7 @@ int main(void) char src[MR_PATH_MAX], dst[MR_PATH_MAX]; snprintf(src, sizeof src, "%s/base2000.jpg", jpgbook); snprintf(dst, sizeof dst, "%s/t%d.jpg", tierbook, i + 1); - if (copy_file(src, dst) != 0) tier_ok = 0; + if (link_or_copy(src, dst) != 0) tier_ok = 0; } if (tier_ok) { mr_reader_ui_test_set_mem(4096, 16000); @@ -843,6 +875,7 @@ int main(void) pump_ms(800); meow_ui_paint_full(); int page0 = mr_reader_ui_page(); + int dec0 = mr_reader_ui_decode_count(); CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, "low tier lands first (tier=%d)", mr_reader_ui_focus_tier()); CHECK(mr_reader_ui_low_decodes() >= 1, "low decode issued (%d)", @@ -851,6 +884,7 @@ int main(void) "no high decode before the settle window (%d)", mr_reader_ui_high_decodes()); long low_kb = mr_reader_ui_slot_kb(page0); CHECK(low_kb > 0, "low tier image resident (%ldKB)", low_kb); + long res_low = mr_reader_ui_resident_kb(); int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); meow_ui_paint_full(); CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, @@ -860,12 +894,15 @@ int main(void) /* 放开升级:边等边采样,焦点槽任何时刻都不能空(不闪白)。 */ int high0 = mr_reader_ui_high_decodes(); long min_slot = low_kb; + size_t op_base = g_live; + peak_reset(); mr_reader_ui_test_set_sharpen_ms(0); for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) { pump_ms(10); long k = mr_reader_ui_slot_kb(page0); if (k > 0 && k < min_slot) min_slot = k; } + size_t op_peak = peak_since(op_base); /* 高档这一次操作的存活增量 */ meow_ui_paint_full(); CHECK(mr_reader_ui_page() == page0, "tier swap keeps the page (%d -> %d)", page0, mr_reader_ui_page()); @@ -875,16 +912,53 @@ int main(void) CHECK(mr_reader_ui_high_decodes() > high0, "high decode issued after settle (%d -> %d)", high0, mr_reader_ui_high_decodes()); - long high_kb = mr_reader_ui_slot_kb(page0); - CHECK(high_kb > 0 && high_kb <= low_kb + 8, - "same-slot swap: one page resident, not two (%ldKB -> %ldKB)", low_kb, - high_kb); + /* P2-2:旧的 `slot_kb` 相等是恒真(两档都落同一个 box)。改成真正会抓问题的 + * 「总常驻不增加」:如果换清晰偷偷多留一张低档,这里立刻涨。 */ + long res_high = mr_reader_ui_resident_kb(); + CHECK(res_high > 0 && res_high <= res_low, + "换清晰不增加常驻(没有第二张低档常驻):%ldKB -> %ldKB", + res_low, res_high); + CHECK(op_peak <= 3 * 1024 * 1024, + "高档单次操作的存活增量 ≤3MB (%zuKB)", op_peak / 1024); + /* 两档共存总账:常驻 + 本次解码 的绝对峰值有上界(不是「基线含低档所以没涨」)。 */ + CHECK(g_peak <= (size_t)(mr_reader_ui_resident_cap_kb() + 3 * 1024) * 1024, + "两档共存绝对峰值 ≤ 常驻上限+3MB (%zuKB)", g_peak / 1024); CHECK(min_slot > 0, "no blank frame while swapping tiers (min slot %ldKB)", min_slot); CHECK(mr_reader_ui_scaled_draws() == sc, "high tier still native blit (scaled %d -> %d)", sc, mr_reader_ui_scaled_draws()); + /* P2-2 之二:换清晰不得重置缩放/平移。先冻结窗口 → 双击放大(低档按新几何重解) + * → 记录缩放/平移 → 放开升级 → 高档落位后两者必须一字不差。 */ + mr_reader_ui_test_set_sharpen_ms(60000); /* 先冻结,让放大后的低档先落位 */ + reader_on_touch_down(240, 360); + reader_on_tap(240, 360); + pump_ms(60); + reader_on_touch_down(240, 360); + reader_on_tap(240, 360); + pump_ms(150); + pump_ms(600); + int zoom_before = mr_reader_ui_zoom_pct(); + int panx_before = mr_reader_ui_pan_x(); + int pany_before = mr_reader_ui_pan_y(); + CHECK(zoom_before > 100, "double tap zoomed in (zoom=%d)", zoom_before); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, + "zoomed page re-decodes to low tier first (tier=%d)", + mr_reader_ui_focus_tier()); + mr_reader_ui_test_set_sharpen_ms(0); + for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) + pump_ms(10); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "zoomed page reaches the target tier (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_zoom_pct() == zoom_before && + mr_reader_ui_pan_x() == panx_before && + mr_reader_ui_pan_y() == pany_before, + "换清晰不重置缩放/平移(zoom %d→%d, pan %d,%d→%d,%d)", + zoom_before, mr_reader_ui_zoom_pct(), panx_before, pany_before, + mr_reader_ui_pan_x(), mr_reader_ui_pan_y()); + /* 拖动中取消高档:拖动期间强塞一个「换清晰」请求,必须被取消通道作废。 */ int hd0 = mr_reader_ui_high_decodes(), hc0 = mr_reader_ui_high_canceled(); reader_on_touch_down(240, 400); @@ -900,7 +974,40 @@ int main(void) mr_reader_ui_high_decodes()); reader_on_tap(240, 400); /* 松手:回弹,不翻页 */ pump_ms(300); - mr_reader_ui_test_set_sharpen_ms(200); /* 恢复产品默认停稳窗口 */ + + /* P2-2 之三:条漫滚动位置在换清晰时不得被重置(换的是视口中心页的档位)。 */ + mr_reader_ui_test_set_sharpen_ms(60000); /* 先冻结:切模式时不要立刻换清晰 */ + mr_reader_test_toggle_mode(); /* 翻页 → 条漫 */ + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "tier book switched to webtoon"); + pump_ms(300); + reader_on_touch_down(240, 600); + for (int k = 1; k <= 5; k++) { + reader_on_touch_move(240, 600 - k * 50); + pump_ms(10); + } + reader_on_tap(240, 300); /* 松手:fling */ + pump_ms(2000); /* 等 fling 停稳 */ + int woff0 = mr_reader_ui_woff(); + int wpage0 = mr_reader_ui_page(); + CHECK(woff0 > 0, "webtoon scrolled to a non-top offset (%d px)", woff0); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, + "webtoon center page is low tier before settle (tier=%d)", + mr_reader_ui_focus_tier()); + mr_reader_ui_test_set_sharpen_ms(0); + for (int i = 0; i < 200 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) + pump_ms(10); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "webtoon center page reaches the target tier (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_woff() == woff0 && mr_reader_ui_page() == wpage0, + "换清晰不重置条漫滚动位置(woff %d→%d, page %d→%d)", + woff0, mr_reader_ui_woff(), wpage0, mr_reader_ui_page()); + mr_reader_ui_test_set_sharpen_ms(150); /* 恢复产品默认停稳窗口 */ + /* P2-3:这一串(开册低+高、放大低+高、条漫低+高、一次被取消的高档)解码次数有界。 */ + int decodes = mr_reader_ui_decode_count() - dec0; + CHECK(decodes <= 12, "两档生命周期解码次数有界 (decodes=%d)", decodes); + INFO("tier lifecycle: decodes=%d resident=%ldKB (低档/高档/放大/条漫 各一次换清晰)", + decodes, mr_reader_ui_resident_kb()); meow_view_page_pop(); pump_ms(200); } diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index d739ef7..de81bbc 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -93,12 +93,18 @@ * 大页(真机 1280×2275 单页解码 392ms)打开/翻到时先按「显示长边的一半」解一版低档, * 落框后立刻上屏;停稳 MR_SHARPEN_MS 后再解目标档位替换同一槽。低档与高档都**精确落到 * 显示框**,所以换图那一下尺寸不变:仍是一次原生 blit,不闪白、不重置缩放/滚动。 - * 内存:同页只占一个槽(高档落地时替换低档),低档的额外峰值只是「小图 + 一次落框重采样」, - * 已由 page_source 的峰值模型计入(step>1 且输出≠显示框时的 +box)。 */ -#define MR_SHARPEN_MS 200 /* 停稳多久后才发起高档解码(150–300ms 区间取中) */ + * 内存(r2,评审 P1-1):高档解码时低档还占着槽,所以「一次操作的总峰值」= + * resident_bytes()(至少含焦点槽低档)+ reader_estimate_peak()(本次解码的峰值); + * 该总峰值受 MR_TIER_TOTAL_CAP 约束(与 page_source 的 12MB 绝对上限同值——常态 3MB + * 只约束 stb 回退路径,后端页连 3MB 都超却已被 M27 放行,拿 3MB 当两档共存上限会让 + * 「先糊后清」在最需要的大页上永远不触发)。超限 → 跳过高档、保留低档(skip=coexist)。 + * 另两条纪律把常驻压到最低(评审 P1-2):分页模式焦点页还是低档时不预取邻居(停稳 150ms 后 + * 就会换清晰,预取来也会被挤掉),高档落位后再按翻页方向补回邻居。 */ +#define MR_SHARPEN_MS 150 /* 停稳多久后才发起高档解码(任务允许 150–300ms;取 150 给「换清晰→补邻居」留出翻页余量) */ #define MR_TIER_MIN_PIXELS 1000000LL /* 源像素少于此值不分档:解本身不慢,分档只添乱 */ #define MR_LOW_MIN_LONG 240 /* 低档输出长边下限(再粗就糊到难看) */ #define MR_LOW_MAX_LONG 480 /* 低档输出长边上限(再细省不了多少解码) */ +#define MR_TIER_TOTAL_CAP (12LL * 1024 * 1024) /* 两档共存:常驻 + 单次解码 的绝对上限 */ /* MR_TIER_LOW / MR_TIER_HIGH 定义在 ui/reader/reader.h(宿主测试也要用同一套档位值)。 */ @@ -197,6 +203,8 @@ typedef struct decode_plan { int want_w, want_h; int box_w, box_h; int tier; /* MR_TIER_LOW / MR_TIER_HIGH(M31) */ + int sharpen; /* 1=「停稳换清晰」的追加请求(要算两档共存峰值) */ + long long resident; /* 发起这次请求时已在槽里的字节(主线程快照) */ } decode_plan; static decode_plan g_plan; static int g_plan_page; /* 本次解码的页号(只用于日志) */ @@ -207,6 +215,7 @@ static volatile int g_decode_count; /* 累计解码次数(观察重解 #define MR_SKIP_NONE 0 /* 解出来了 */ #define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */ #define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */ +#define MR_SKIP_COEXIST 3 /* 两档共存总峰值超上限:跳过高档、保留低档 */ static int page_failed(int idx) { @@ -262,6 +271,7 @@ typedef struct page_req { int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */ unsigned char tier; /* MR_TIER_*(M31:低档过渡 / 目标档位) */ unsigned char sharpen; /* 1=「停稳后换清晰」的追加请求:动起来就作废 */ + long long resident; /* 发起时的常驻字节(M31 r2:高档要算两档共存峰值) */ } page_req; typedef struct page_res { int idx; @@ -271,6 +281,7 @@ typedef struct page_res { int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; unsigned char tier; /* MR_TIER_*:结果按哪一档解出来的 */ + unsigned char sharpen; /* 1=这是一次「停稳换清晰」的结果(落位后要补邻居) */ } page_res; static pthread_t g_worker; @@ -288,14 +299,24 @@ static int g_res_n; * g_sharpen_idx:当前唯一允许解高档的页(-1=拖动/未停稳/无需升级)。worker 在锁内据此 * 作废「在飞的高档请求」——复用 M22 已有的 gen/窗口取消机制,只是多一条判据。 * g_move_ms:最近一次「动」的时刻(按下/拖动/吸附动画/条漫滚动/fling/换页); - * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 */ + * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 + * g_step_dir:最近一次翻页/跳页的方向(+1 页号增,-1 页号减);高档落位后按这个方向优先 + * 补邻居(P1-2:不在反方向白解一张马上会被挤掉的图)。 + * g_refill:高档落位 → tick 里补回邻居(换清晰会为省峰值腾掉邻居,不补就每次翻页都 miss)。 */ static int g_sharpen_idx = -1; -static int g_hi_inflight = -1; /* 已排队/正在解的目标档位页(去重用;锁内读写) */ +/* 每页是否有请求已排队/正在 worker 手里(去重)。M31 r2(评审 P1-2):只靠队列扫描不够—— + * 请求被 worker 取走后就不在队列里了,窗口刷新会再排一次同一页(重复解码 + 挤掉刚预取的 + * 邻居,正是「每翻一页解 4 次 / 0 命中」的来源)。锁内读写。 */ +static unsigned char g_req_inflight[MR_BOOK_MAX_PAGES]; static uint64_t g_move_ms; static int g_sharpen_ms = MR_SHARPEN_MS; +static int g_step_dir = 1; +static int g_refill; static volatile int g_low_decodes; /* 完成的低档解码次数(宿主测试/日志对照) */ static volatile int g_high_decodes; /* 完成的目标档位解码次数 */ static volatile int g_high_canceled; /* 被取消的高档请求数(拖动/换页/停稳失效) */ +static volatile int g_flip_hits; /* 翻页时目标页已在槽(预取命中)次数 */ +static volatile int g_flip_misses; /* 翻页时目标页还没解好(要等解码)次数 */ /* ---- 节拍/动画 ---- */ static meow_anim *g_ticker; @@ -350,6 +371,7 @@ static page_slot *slot_find(int idx); static void cont_touch(void); static void cont_save_now(void); static int current_page(void); +static int page_needs_tier(int idx); /* ============================================================ */ /* 内存读数与观测(M22) */ @@ -574,6 +596,17 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); } g_decode_tier = tier; /* 实际生效的档位(worker 据此计数/落槽) */ + /* 两档共存(M31 r2 / 评审 P1-1):高档解码时低档仍在槽里显示,所以这次操作的 + * 真实峰值 = 已在槽里的常驻 + 本次解码峰值。M27 的模型只描述后者;这里把前者 + * 加进同一个上限(MR_TIER_TOTAL_CAP),超了就跳过高档、保留低档(页面仍可读)。 */ + if (g_plan.sharpen && dec.skip == MR_DECODE_OK && + g_plan.resident + dec.peak_bytes > MR_TIER_TOTAL_CAP) { + g_decode_skip = MR_SKIP_COEXIST; + log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, "skip=coexist", + dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier, + (unsigned long long)(meow_ui_time_ms() - t0)); + return NULL; + } if (dec.skip != MR_DECODE_OK) { g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, @@ -937,11 +970,20 @@ static int req_queued(int idx) } /* 预取闸门(任务 4):拖动/惯性期间只解落点页,不做邻居预取;且只在内存确实富裕时预取。 - * MemAvailable 读不到按「不预取」处理(保守)。 */ + * MemAvailable 读不到按「不预取」处理(保守)。 + * M31 r2(评审 P1-2):分页模式下焦点页还是低档 → 停稳 150ms 后就要换清晰,而换清晰为了 + * 压低两档共存峰值会腾掉非可见页;此刻预取的邻居正好会被挤掉(白解一次,且换完不补就 + * 每次翻页 miss)。所以「焦点页等着换清晰」期间不预取邻居,等高档落位后由 sharpen_refill() + * 按翻页方向补回来。低档已放弃升级(hi_tried)或焦点已清晰时恢复正常预取。 */ static int prefetch_allowed(void) { if (g_dragging) return 0; if (g_scroll && meow_scroll_active(g_scroll)) return 0; + if (g_mode == MR_MODE_PAGED) { + int f = focus_page(); + page_slot *s = f >= 0 ? slot_find(f) : NULL; + if (s && s->tier == MR_TIER_LOW && !s->hi_tried && page_needs_tier(f)) return 0; + } long a = mem_avail_kb(); if (a < 0) return 0; return a >= MR_PREFETCH_MIN_KB; @@ -970,10 +1012,11 @@ static int queue_page_tier(int idx, int tier, int sharpen) slots_reserve((long)box_w * box_h * 4); int queued = 0; pthread_mutex_lock(&g_lock); - /* 去重:已排队(req_queued)或「换清晰」请求正在 worker 手里(g_hi_inflight)都不再发。 - * 后者必须单列:请求被 worker 取走后就不在队列里了,只看 req_queued 会每 tick 重发一次。 */ - if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX && - !(sharpen && g_hi_inflight == idx)) { + /* 去重:同一页只要已排队或已交给 worker(g_req_inflight)就不再发。后者必须单列: + * 请求被 worker 取走后就不在队列里了,只看 req_queued 会在每次窗口刷新时重排一页, + * 重复解码还会把刚预取的邻居挤掉(评审 P1-2 的 4 次/页、0 命中)。 */ + int busy = idx >= 0 && idx < MR_BOOK_MAX_PAGES && g_req_inflight[idx]; + if (!g_stop && !busy && !req_queued(idx) && g_req_n < MR_REQ_MAX) { page_req *r = &g_req[g_req_tail]; r->idx = idx; r->gen = g_gen; @@ -983,9 +1026,10 @@ static int queue_page_tier(int idx, int tier, int sharpen) r->box_h = box_h; r->tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); r->sharpen = (unsigned char)(sharpen ? 1 : 0); + r->resident = (long long)resident_bytes(); /* 腾完地方之后的常驻快照(两档共存峰值) */ g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; - if (sharpen) g_hi_inflight = idx; + if (idx >= 0 && idx < MR_BOOK_MAX_PAGES) g_req_inflight[idx] = 1; queued = 1; pthread_cond_signal(&g_cv); } @@ -999,6 +1043,7 @@ static void queue_page(int idx) /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ if (page_blocked(idx)) return; page_slot *s = slot_find(idx); + if (g_req_inflight[idx]) return; /* 已排队/在解:别重排(重复解码会挤掉邻居) */ if (s && slot_ready(idx)) { /* 已经能显示:只有「低档 → 目标档位」这一种情况还要再解,而且必须是当前焦点页、 * 已停稳(g_sharpen_idx 由 tier_pump 在去抖窗口后置位)、并且最近一次升级没失败过。 @@ -1035,7 +1080,7 @@ static void *reader_worker(void *arg) stale = 1; stale_motion = 1; } - if (stale && req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; + if (stale && req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0; pthread_mutex_unlock(&g_lock); if (stale) { if (stale_motion) g_high_canceled++; @@ -1052,6 +1097,8 @@ static void *reader_worker(void *arg) g_plan.box_w = req.box_w; g_plan.box_h = req.box_h; g_plan.tier = req.tier; + g_plan.sharpen = req.sharpen; + g_plan.resident = req.resident; g_plan_page = req.idx; g_decode_skip = MR_SKIP_NONE; g_decode_tier = MR_TIER_HIGH; @@ -1074,11 +1121,12 @@ static void *reader_worker(void *arg) g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; g_res[g_res_n].tier = (unsigned char)eff_tier; + g_res[g_res_n].sharpen = req.sharpen; g_res_n++; img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } - /* 本页的「换清晰」请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ - if (req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; + /* 本页请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ + if (req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0; pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); } @@ -1107,7 +1155,7 @@ static void worker_stop(void) g_res_n = 0; g_req_n = 0; g_req_head = g_req_tail = 0; - g_hi_inflight = -1; + memset(g_req_inflight, 0, sizeof g_req_inflight); pthread_mutex_unlock(&g_lock); } @@ -1120,16 +1168,22 @@ static void queue_prune(void) int n = 0; for (int i = 0; i < g_req_n; i++) { page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; - if (r.gen != g_gen || !in_window(r.idx)) continue; - if (r.sharpen && r.idx != g_sharpen_idx) { - if (g_hi_inflight == r.idx) g_hi_inflight = -1; + int drop = 0; + if (r.gen != g_gen || !in_window(r.idx)) drop = 1; + if (!drop && r.sharpen && r.idx != g_sharpen_idx) { g_high_canceled++; - continue; + drop = 1; } /* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */ - int w = 0, h = 0; - page_geometry_at(r.idx, &w, &h); - if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) continue; + if (!drop) { + int w = 0, h = 0; + page_geometry_at(r.idx, &w, &h); + if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) drop = 1; + } + if (drop) { + if (r.idx >= 0 && r.idx < MR_BOOK_MAX_PAGES) g_req_inflight[r.idx] = 0; + continue; + } keep[n++] = r; } for (int i = 0; i < n; i++) g_req[i] = keep[i]; @@ -1159,7 +1213,7 @@ static int results_drain(void) page_slot *low = got[i].tier == MR_TIER_HIGH ? slot_find(got[i].idx) : NULL; if (low && low->tier != MR_TIER_LOW) low = NULL; /* 已有目标档:不是「低档兜底」 */ if (got[i].skip) { - /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ + /* 预算跳过(内存不足/峰值超限/两档共存超上限):可重试,不写永久失败位。 */ if (low) { low->hi_tried = 1; continue; } /* 低档在屏:降级显示,不占位 */ page_mark_skipped(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; @@ -1176,6 +1230,9 @@ static int results_drain(void) got[i].tier) && page_visible(got[i].idx)) changed = 1; page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ + /* 换清晰落位:换清晰前为压峰值腾掉了邻居,这里补回(P1-2)。 + * 普通预取/首次解码不腾邻居,不需要补。 */ + if (got[i].sharpen) g_refill = 1; } else { meow_img_free(got[i].img); } @@ -1285,6 +1342,20 @@ static void tier_pump(uint64_t now) queue_page(f); /* 幂等:已排队/在飞/已清晰/已试过都直接返回 */ } +/* 高档落位后补邻居(M31 r2 / 评审 P1-2)。 + * 换清晰前 slots_reserve 会腾掉非可见页来压两档共存峰值,顺序翻页时被腾掉的正是 + * 「下一页」的预取;不补回来,每次翻页都会 miss(基线 8/8 → 分档后 0/8)。 + * 只补最近一次翻页方向上的邻居:反方向那张在 3MB 常驻上限下放不下(必然被下一次预取 + * 挤掉),白解一次还抬高解码次数;回头看时按需解一版低档——低档本来就是为这个设计的。 */ +static void sharpen_refill(void) +{ + if (g_mode != MR_MODE_PAGED || g_count <= 0 || g_quitting) return; + int f = focus_page(); + if (f < 0) return; + int fwd = f + (g_step_dir >= 0 ? 1 : -1); + if (fwd >= 0 && fwd < g_count && !slot_find(fwd)) queue_page(fwd); +} + /* ============================================================ */ /* 条漫排版 + 滚动会话 */ /* ============================================================ */ @@ -1636,9 +1707,11 @@ static void pan_by(int dx, int dy) static void commit_page(int target) { + int prev = g_page; target = mr_nav_clamp_page(target, g_count); if (target < 0) return; g_page = target; + if (target != prev) g_step_dir = target > prev ? 1 : -1; /* 高档落位后按这个方向补邻居 */ g_zoom_pct = 100; g_pan_x = g_pan_y = 0; settle_reset(); /* 换页:停稳计时重来(拖动/连翻期间不解高档) */ @@ -1748,11 +1821,13 @@ static void page_flip(int delta) g_flip_t0 = meow_ui_time_ms(); if (slot_find(target)) { + g_flip_hits++; if (start_slide(delta, 0)) meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1); else meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1); } else { + g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); } @@ -1764,8 +1839,10 @@ static void page_flip(int delta) static void jump_land(int page, int timed_out) { if (g_count <= 0) return; + int prev = current_page(); page = mr_nav_clamp_page(page, g_count); if (page < 0) page = 0; + if (page != prev) g_step_dir = page > prev ? 1 : -1; /* 高档落位后按跳页方向补邻居 */ if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { if (g_scroll) meow_scroll_stop(g_scroll); g_woff = mr_strip_offset_for_page(&g_strip, page); @@ -1907,8 +1984,10 @@ static void open_book_reset_state(void) g_toast[0] = '\0'; g_title_page = -1; g_sharpen_idx = -1; /* 新册:高档资格清零,停稳计时重来 */ - g_hi_inflight = -1; + memset(g_req_inflight, 0, sizeof g_req_inflight); g_move_ms = meow_ui_time_ms(); + g_step_dir = 1; + g_refill = 0; free(g_meta); g_meta = NULL; } @@ -2092,8 +2171,10 @@ static void drag_finish(void) } g_flip_t0 = meow_ui_time_ms(); if (slot_find(target) && start_slide(delta, dx)) { + g_flip_hits++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); } else { + g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); @@ -2406,6 +2487,12 @@ static void reader_tick(void) /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ if (results_drain() && !g_dragging) invalidate_content(); + /* 高档落位后补回换清晰时为压峰值腾掉的邻居(P1-2:否则顺序翻页每次都 miss)。 */ + if (g_refill) { + g_refill = 0; + if (!g_dragging) sharpen_refill(); + } + /* 两级清晰度:停稳去抖(拖动/滚动期间只留低档,停稳后才补高档)。 */ tier_pump(now); @@ -2566,11 +2653,15 @@ static void reader_state_reset(void) g_decode_skip = MR_SKIP_NONE; g_decode_tier = MR_TIER_HIGH; g_sharpen_idx = -1; - g_hi_inflight = -1; + memset(g_req_inflight, 0, sizeof g_req_inflight); g_move_ms = meow_ui_time_ms(); + g_step_dir = 1; + g_refill = 0; g_low_decodes = 0; g_high_decodes = 0; g_high_canceled = 0; + g_flip_hits = 0; + g_flip_misses = 0; } static void reader_page_enter(meow_view *v, void *ctx) @@ -2810,6 +2901,14 @@ int mr_reader_ui_low_decodes(void) { return g_low_decodes; } int mr_reader_ui_high_decodes(void) { return g_high_decodes; } int mr_reader_ui_high_canceled(void) { return g_high_canceled; } int mr_reader_ui_sharpen_idx(void) { return g_sharpen_idx; } +/* 翻页命中/未命中:目标页已在槽(= 预取生效,翻页不用等解码)/ 还没解好。 */ +int mr_reader_ui_flip_hits(void) { return g_flip_hits; } +int mr_reader_ui_flip_misses(void) { return g_flip_misses; } +/* 视图位置(M31 r2 评审 P2-2:换清晰不得重置缩放/平移/条漫滚动位置)。 */ +int mr_reader_ui_zoom_pct(void) { return g_zoom_pct; } +int mr_reader_ui_pan_x(void) { return g_pan_x; } +int mr_reader_ui_pan_y(void) { return g_pan_y; } +int mr_reader_ui_woff(void) { return g_woff; } /* 宿主测试改写「停稳」窗口:0=立刻允许高档(同步验证落位),很大=冻结高档(验证低档先落位)。 */ void mr_reader_ui_test_set_sharpen_ms(int ms) { diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 960a43b..c9c9df1 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -64,7 +64,13 @@ int mr_reader_ui_low_decodes(void); /* 低档解码次数 */ int mr_reader_ui_high_decodes(void);/* 目标档位解码次数 */ int mr_reader_ui_high_canceled(void);/* 被取消(拖动/换页/未停稳)的高档请求数 */ int mr_reader_ui_sharpen_idx(void); /* 当前允许升级的页;-1=拖动/未停稳/无需升级 */ -/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=200ms):0=立刻允许高档,很大=冻结高档。 */ +int mr_reader_ui_flip_hits(void); /* 翻页时目标页已在槽(预取命中,不用等解码)次数 */ +int mr_reader_ui_flip_misses(void); /* 翻页时目标页还没解好(要等解码)次数 */ +int mr_reader_ui_zoom_pct(void); /* 当前缩放档(100=未放大) */ +int mr_reader_ui_pan_x(void); /* 当前平移(px) */ +int mr_reader_ui_pan_y(void); +int mr_reader_ui_woff(void); /* 条漫滚动偏移(px) */ +/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=150ms):0=立刻允许高档,很大=冻结高档。 */ void mr_reader_ui_test_set_sharpen_ms(int ms); /* 绕过停稳判定强排一个「换清晰」请求(验证拖动中会被 M22 的取消通道作废)。 */ void mr_reader_ui_test_queue_high(int idx); From bbc73635a67e6e75b1ae02927b4a6bb53793089e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 18:33:05 +0800 Subject: [PATCH 34/38] Revert "Merge branch 'feat/reader-low-res-first-then-sharpen'" This reverts commit eb0750051f21420ba069b20d131a280707eb2e7e, reversing changes made to ce349747277cb6febceb663caa9b0e6dd785af0e. --- tuku/test/test_reader_membudget.c | 256 ++-------------- tuku/test/test_reader_perf.c | 46 +-- tuku/ui/reader/reader.c | 491 +++--------------------------- tuku/ui/reader/reader.h | 23 -- 4 files changed, 71 insertions(+), 745 deletions(-) diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index 32c6e43..e83cae6 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -17,12 +17,7 @@ * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 * B7c P1-1 旁路回归:截断 12MP JPEG / 3000×3000 24bpp 头-only BMP(判定 backend, * 后端失败必须 NULL、峰值 ≤3MB)/ 非法位深 PNG(矩阵拒绝→stb 占位) - * B8 M27/M31:顺序连翻 100 页(101 页册,6 类循环,每页都是不同的下一页)不掉页、 - * 不占位、存活增量 ≤3MB、绝对峰值有上界、预取命中 ≥90%、解码次数 ≤2/页+余量 - * (M31 r2:修「每翻一页解 4 次」与高峰值,评审 P1-1/P1-2/P2-1/P2-3) - * B10 M31:两级清晰度生命周期(JPEG 大页):低档先落位(open 不阻塞)→ 停稳后高档落 - * 同一槽(总常驻不增加、槽位不空、本次操作增量 ≤3MB)→ 双击放大换清晰不重置缩放/平移 - * → 拖动中强塞的高档请求被取消通道作废 → 整串解码次数有界 + * B8 M27:连翻 30 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 @@ -120,7 +115,6 @@ static int g_fail; } while (0) #define INFO(...) do { printf(" info: "); printf(__VA_ARGS__); printf("\n"); } while (0) - /* ------------------------------------------------------------------ */ /* 夹具写入 */ /* ------------------------------------------------------------------ */ @@ -221,14 +215,6 @@ static int copy_file(const char *src, const char *dst) return ok ? 0 : -1; } -/* 同一文件系统优先硬链接:多页册只是「同样的夹具当很多页」,101 页 × 12MP/BMP/PNG 真拷贝 - * 要写几百 MB,跑一次测试 I/O 比解码还久(评审复跑成本)。读侧只按路径打开,硬链接完全等价。 */ -static int link_or_copy(const char *src, const char *dst) -{ - if (link(src, dst) == 0) return 0; - return copy_file(src, dst); -} - static int have_file(const char *path) { struct stat st; @@ -475,23 +461,20 @@ int main(void) } int have_jpeg = gen_jpeg_fixtures(jpgbook); - /* 顺序连翻 100 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 - * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 - * M31 r2(评审 P2-1):101 页顺序遍历(不是 30 页来回跳)——每次翻页都落到**不同的 - * 下一页**,4MP/12MP 夹具都会真正成为焦点页并被解码/换清晰。 */ + /* 连翻 30 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 + * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 */ static const char *const g_mix[] = { "base2000.jpg", "prog2000.jpg", "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", }; const int mix_n = (int)(sizeof g_mix / sizeof g_mix[0]); - const int mixed_goal = 101; /* 100 次前翻 → 第 101 页(不回卷、不触发册间衔接) */ int mixed_pages = 0; if (have_jpeg) { - for (int i = 0; i < mixed_goal; i++) { + for (int i = 0; i < 30; i++) { char src[MR_PATH_MAX], dst[MR_PATH_MAX]; snprintf(src, sizeof src, "%s/%s", jpgbook, g_mix[i % mix_n]); - snprintf(dst, sizeof dst, "%s/p%03d_%s", mixed, i, g_mix[i % mix_n]); - if (link_or_copy(src, dst) == 0) mixed_pages++; + snprintf(dst, sizeof dst, "%s/p%02d_%s", mixed, i, g_mix[i % mix_n]); + if (copy_file(src, dst) == 0) mixed_pages++; } } /* 回退路径集成:CMYK(4 分量)必须占位且不崩。 */ @@ -608,9 +591,6 @@ int main(void) mr_reader_ui_failed_pages()); /* 取消/预取纪律:100 次翻页的解码次数必须有界(M20 实测曾 1.75× 浪费)。 */ CHECK(decodes <= 100 * 2, "decode count bounded over 100 flips (%d)", decodes); - /* M31:小页(480×640=0.3MP)不分档——低档只会多一次解码 + 落框,纯开销。 */ - CHECK(mr_reader_ui_low_decodes() == 0, - "small pages stay single-tier (%d low decodes)", mr_reader_ui_low_decodes()); meow_view_page_pop(); pump_ms(200); } @@ -782,237 +762,43 @@ int main(void) printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } - /* ---- B8 M27/M31:顺序连翻 100 页(101 页册,6 类典型页循环)——不掉页、不占位、 - * 峰值不增长、预取命中、解码次数有界 ---- - * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档)。评审 P1: - * - 顺序前翻(每页都落到不同的下一页),4MP/12MP 夹具真正成为焦点页; - * - 断言语义翻页命中率(目标页已在槽)与解码次数上限,防止「每翻一页解 4 次」; - * - 存活增量仍按单页解码峰值封顶(两档共存时换清晰前会腾掉非可见页,见 reader.c)。 */ - if (have_jpeg && mixed_pages == mixed_goal) { + /* ---- B8 M27:连翻 30 页(6 类典型页循环)——不掉页、不占位、峰值 ≤3MB ---- */ + if (have_jpeg && mixed_pages == 30) { mr_reader_ui_test_set_mem(4096, 16000); mr_reader_open_dir(mixed); pump_ms(1200); - CHECK(mr_reader_ui_count() == mixed_goal, "mixed book pages=%d", mr_reader_ui_count()); + CHECK(mr_reader_ui_count() == 30, "mixed book pages=%d", mr_reader_ui_count()); + int w = meow_ui_width(); int advanced = 0, skipped_max = 0; size_t live_steady = g_live; int d0 = mr_reader_ui_decode_count(); - int low0 = mr_reader_ui_low_decodes(), high0 = mr_reader_ui_high_decodes(); - int hits0 = mr_reader_ui_flip_hits(), miss0 = mr_reader_ui_flip_misses(); /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 - * 「分配新图 + 解码」的真实峰值;指标 = 整个 100 翻窗口内相对稳态基线的最大存活增量。 + * 「分配新图 + 解码」的真实峰值;指标 = 整个 30 翻窗口内相对稳态基线的最大存活增量。 * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ size_t base = peak_reset(); - for (int i = 0; i < 100; i++) { + for (int i = 0; i < 30; i++) { int before = mr_reader_ui_page(); - tap(40, 300); /* RTL:左 1/3 = 下一页(顺序前翻) */ - pump_ms(400); /* 700ms/页:给「换清晰→补邻居」留出余量 */ - if (mr_reader_ui_page() == before + 1) advanced++; + tap(i % 2 == 0 ? 40 : w - 40, 300); + pump_ms(220); + if (mr_reader_ui_page() != before) advanced++; if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); } size_t transient = g_peak > base ? g_peak - base : 0; - int hits = mr_reader_ui_flip_hits() - hits0; - int miss = mr_reader_ui_flip_misses() - miss0; - int decodes = mr_reader_ui_decode_count() - d0; - INFO("100 mixed flips: advanced=%d last_page=%d hits=%d miss=%d skipped=%d decodes=%d " - "(low=%d high=%d) steady=%zuKB resident=%ldKB max_live_delta=%zuKB abs_peak=%zuKB", - advanced, mr_reader_ui_page(), hits, miss, skipped_max, decodes, - mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0, - live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024, g_peak / 1024); - CHECK(advanced == 100 && mr_reader_ui_page() == 100, - "100 次顺序前翻逐页推进(advanced=%d last=%d)", advanced, mr_reader_ui_page()); + INFO("30 mixed flips: advanced=%d skipped=%d decodes=%d steady=%zuKB " + "resident=%ldKB max_live_delta=%zuKB", + advanced, skipped_max, mr_reader_ui_decode_count() - d0, + live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); + CHECK(advanced == 30, "30 页全部翻到 (%d/30)", advanced); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); - CHECK(mr_reader_ui_low_decodes() > low0 && mr_reader_ui_high_decodes() > high0, - "大页确实走了两级(low +%d, high +%d)", - mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0); - /* 顺序阅读的预取命中率:评审要求不低于基线 7/8。 */ - CHECK(hits >= 90, "顺序翻页预取命中率 ≥90%%(hit=%d miss=%d)", hits, miss); - /* 解码次数上限:分档页 = 低档 + 高档(2 次/页),非 JPEG 单档(1 次/页)+ 开册/补邻居余量。 - * 出现「每页 4 次」的放大(P1-2 的病灶)时这里会立刻失败。 */ - CHECK(decodes <= 100 * 2 + 20, "100 次翻页解码次数 ≤2/页+余量 (decodes=%d)", decodes); - CHECK(mr_reader_ui_resident_kb() <= mr_reader_ui_resident_cap_kb(), - "常驻仍在上限内 (%ldKB ≤ %ldKB)", mr_reader_ui_resident_kb(), - mr_reader_ui_resident_cap_kb()); CHECK(transient <= 3 * 1024 * 1024, - "100 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); - /* 两档共存的总账:常驻(≤3MB)+ 单次解码(≤3MB)——不许因为「基线里已含低档」 - * 就看着没涨;绝对存活峰值同样要有上界。 */ - CHECK(g_peak <= (size_t)(mr_reader_ui_resident_cap_kb() + 3 * 1024) * 1024, - "绝对存活峰值 ≤ 常驻上限+3MB (%zuKB)", g_peak / 1024); + "30 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); meow_view_page_pop(); pump_ms(200); } - /* ---- B10 M31:两级清晰度生命周期(2000×3000 JPEG,真机同类大页)---- - * 1) 冻结停稳窗口 → 只解低档:焦点页 tier=low、常驻=显示框大小(仍是一次原生 blit), - * 主线程不等解码(open 立即返回); - * 2) 放开停稳窗口 → 高档落同一槽:tier=high、**总常驻不增加**(不是恒真的槽内字节比较)、 - * 槽位全程不空、本次操作存活增量 ≤3MB、两档共存绝对峰值有上界; - * 3) 双击放大后按新几何先落低档,再换清晰 → 缩放/平移一字不差(P2-2); - * 4) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来; - * 5) 整串生命周期解码次数有界(P2-3)。 */ - if (have_jpeg) { - char tierbook[MR_PATH_MAX]; - snprintf(tierbook, sizeof tierbook, "%s/tier", root); - int tier_ok = mkdir_p(tierbook) == 0; - for (int i = 0; tier_ok && i < 2; i++) { - char src[MR_PATH_MAX], dst[MR_PATH_MAX]; - snprintf(src, sizeof src, "%s/base2000.jpg", jpgbook); - snprintf(dst, sizeof dst, "%s/t%d.jpg", tierbook, i + 1); - if (link_or_copy(src, dst) != 0) tier_ok = 0; - } - if (tier_ok) { - mr_reader_ui_test_set_mem(4096, 16000); - mr_reader_ui_test_set_sharpen_ms(60000); /* 冻结:先验证低档先落位 */ - uint64_t t_open = (uint64_t)meow_ui_time_ms(); - mr_reader_open_dir(tierbook); - uint64_t open_ms = (uint64_t)meow_ui_time_ms() - t_open; - CHECK(mr_reader_ui_count() == 2, "tier book pages=%d", mr_reader_ui_count()); - CHECK(open_ms < 500, "open is non-blocking (%llu ms)", - (unsigned long long)open_ms); - pump_ms(800); - meow_ui_paint_full(); - int page0 = mr_reader_ui_page(); - int dec0 = mr_reader_ui_decode_count(); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, - "low tier lands first (tier=%d)", mr_reader_ui_focus_tier()); - CHECK(mr_reader_ui_low_decodes() >= 1, "low decode issued (%d)", - mr_reader_ui_low_decodes()); - CHECK(mr_reader_ui_high_decodes() == 0, - "no high decode before the settle window (%d)", mr_reader_ui_high_decodes()); - long low_kb = mr_reader_ui_slot_kb(page0); - CHECK(low_kb > 0, "low tier image resident (%ldKB)", low_kb); - long res_low = mr_reader_ui_resident_kb(); - int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); - meow_ui_paint_full(); - CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, - "low tier draws as native blit (blit +%d, scaled +%d)", - mr_reader_ui_blit_draws() - bl, mr_reader_ui_scaled_draws() - sc); - - /* 放开升级:边等边采样,焦点槽任何时刻都不能空(不闪白)。 */ - int high0 = mr_reader_ui_high_decodes(); - long min_slot = low_kb; - size_t op_base = g_live; - peak_reset(); - mr_reader_ui_test_set_sharpen_ms(0); - for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) { - pump_ms(10); - long k = mr_reader_ui_slot_kb(page0); - if (k > 0 && k < min_slot) min_slot = k; - } - size_t op_peak = peak_since(op_base); /* 高档这一次操作的存活增量 */ - meow_ui_paint_full(); - CHECK(mr_reader_ui_page() == page0, "tier swap keeps the page (%d -> %d)", - page0, mr_reader_ui_page()); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, - "high tier replaces low in the same slot (tier=%d)", - mr_reader_ui_focus_tier()); - CHECK(mr_reader_ui_high_decodes() > high0, - "high decode issued after settle (%d -> %d)", high0, - mr_reader_ui_high_decodes()); - /* P2-2:旧的 `slot_kb` 相等是恒真(两档都落同一个 box)。改成真正会抓问题的 - * 「总常驻不增加」:如果换清晰偷偷多留一张低档,这里立刻涨。 */ - long res_high = mr_reader_ui_resident_kb(); - CHECK(res_high > 0 && res_high <= res_low, - "换清晰不增加常驻(没有第二张低档常驻):%ldKB -> %ldKB", - res_low, res_high); - CHECK(op_peak <= 3 * 1024 * 1024, - "高档单次操作的存活增量 ≤3MB (%zuKB)", op_peak / 1024); - /* 两档共存总账:常驻 + 本次解码 的绝对峰值有上界(不是「基线含低档所以没涨」)。 */ - CHECK(g_peak <= (size_t)(mr_reader_ui_resident_cap_kb() + 3 * 1024) * 1024, - "两档共存绝对峰值 ≤ 常驻上限+3MB (%zuKB)", g_peak / 1024); - CHECK(min_slot > 0, "no blank frame while swapping tiers (min slot %ldKB)", - min_slot); - CHECK(mr_reader_ui_scaled_draws() == sc, - "high tier still native blit (scaled %d -> %d)", sc, - mr_reader_ui_scaled_draws()); - - /* P2-2 之二:换清晰不得重置缩放/平移。先冻结窗口 → 双击放大(低档按新几何重解) - * → 记录缩放/平移 → 放开升级 → 高档落位后两者必须一字不差。 */ - mr_reader_ui_test_set_sharpen_ms(60000); /* 先冻结,让放大后的低档先落位 */ - reader_on_touch_down(240, 360); - reader_on_tap(240, 360); - pump_ms(60); - reader_on_touch_down(240, 360); - reader_on_tap(240, 360); - pump_ms(150); - pump_ms(600); - int zoom_before = mr_reader_ui_zoom_pct(); - int panx_before = mr_reader_ui_pan_x(); - int pany_before = mr_reader_ui_pan_y(); - CHECK(zoom_before > 100, "double tap zoomed in (zoom=%d)", zoom_before); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, - "zoomed page re-decodes to low tier first (tier=%d)", - mr_reader_ui_focus_tier()); - mr_reader_ui_test_set_sharpen_ms(0); - for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) - pump_ms(10); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, - "zoomed page reaches the target tier (tier=%d)", - mr_reader_ui_focus_tier()); - CHECK(mr_reader_ui_zoom_pct() == zoom_before && - mr_reader_ui_pan_x() == panx_before && - mr_reader_ui_pan_y() == pany_before, - "换清晰不重置缩放/平移(zoom %d→%d, pan %d,%d→%d,%d)", - zoom_before, mr_reader_ui_zoom_pct(), panx_before, pany_before, - mr_reader_ui_pan_x(), mr_reader_ui_pan_y()); - - /* 拖动中取消高档:拖动期间强塞一个「换清晰」请求,必须被取消通道作废。 */ - int hd0 = mr_reader_ui_high_decodes(), hc0 = mr_reader_ui_high_canceled(); - reader_on_touch_down(240, 400); - reader_on_touch_move(180, 400); /* 进入拖动 → g_sharpen_idx=-1 */ - CHECK(mr_reader_ui_sharpen_idx() == -1, "drag clears sharpen eligibility"); - mr_reader_ui_test_queue_high(page0); - pump_ms(200); - CHECK(mr_reader_ui_high_canceled() > hc0, - "high request canceled while dragging (%d -> %d)", hc0, - mr_reader_ui_high_canceled()); - CHECK(mr_reader_ui_high_decodes() == hd0, - "canceled high request is never decoded (%d -> %d)", hd0, - mr_reader_ui_high_decodes()); - reader_on_tap(240, 400); /* 松手:回弹,不翻页 */ - pump_ms(300); - - /* P2-2 之三:条漫滚动位置在换清晰时不得被重置(换的是视口中心页的档位)。 */ - mr_reader_ui_test_set_sharpen_ms(60000); /* 先冻结:切模式时不要立刻换清晰 */ - mr_reader_test_toggle_mode(); /* 翻页 → 条漫 */ - CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "tier book switched to webtoon"); - pump_ms(300); - reader_on_touch_down(240, 600); - for (int k = 1; k <= 5; k++) { - reader_on_touch_move(240, 600 - k * 50); - pump_ms(10); - } - reader_on_tap(240, 300); /* 松手:fling */ - pump_ms(2000); /* 等 fling 停稳 */ - int woff0 = mr_reader_ui_woff(); - int wpage0 = mr_reader_ui_page(); - CHECK(woff0 > 0, "webtoon scrolled to a non-top offset (%d px)", woff0); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, - "webtoon center page is low tier before settle (tier=%d)", - mr_reader_ui_focus_tier()); - mr_reader_ui_test_set_sharpen_ms(0); - for (int i = 0; i < 200 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) - pump_ms(10); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, - "webtoon center page reaches the target tier (tier=%d)", - mr_reader_ui_focus_tier()); - CHECK(mr_reader_ui_woff() == woff0 && mr_reader_ui_page() == wpage0, - "换清晰不重置条漫滚动位置(woff %d→%d, page %d→%d)", - woff0, mr_reader_ui_woff(), wpage0, mr_reader_ui_page()); - mr_reader_ui_test_set_sharpen_ms(150); /* 恢复产品默认停稳窗口 */ - /* P2-3:这一串(开册低+高、放大低+高、条漫低+高、一次被取消的高档)解码次数有界。 */ - int decodes = mr_reader_ui_decode_count() - dec0; - CHECK(decodes <= 12, "两档生命周期解码次数有界 (decodes=%d)", decodes); - INFO("tier lifecycle: decodes=%d resident=%ldKB (低档/高档/放大/条漫 各一次换清晰)", - decodes, mr_reader_ui_resident_kb()); - meow_view_page_pop(); - pump_ms(200); - } - } - /* ---- B9 M27:回退路径集成 —— CMYK 页占位(可重试)且不崩 ---- */ if (have_fallback) { mr_reader_ui_test_set_mem(4096, 16000); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 9692da2..5a84afc 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -15,12 +15,7 @@ * P5 「先点适高,再切条漫」:scaled 不增长(评审点名的第二入口); * P6 双击放大再双击缩回:缩回后按新几何重解 → scaled 不增长(P2-1); * P7 静止若干帧不产生新解码(无重解抖动/活锁,P2-2); - * P8 驻留页图有字节上界(<= 9MB); - * P9 800×1280(评审表格另一档):翻页 + 条漫都必须零重采样; - * P10 大屏(1080×1920):fit 目标被字节上限钳住后不得反复重解; - * P11 分档策略(M31):非 JPEG 大页不分档(一次解到目标档、仍是原生 blit); - * 低档先落位→停稳换清晰→拖动取消的完整生命周期在 test_reader_membudget 的 B10 - * 用真实 JPEG 夹具覆盖。 + * P8 驻留页图有字节上界(<= 9MB)。 * * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 */ @@ -373,45 +368,6 @@ int main(void) "800x1280 webtoon: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); - /* ---- P11 分档策略(M31):非 JPEG 大页不分档——一次解到目标档,仍是原生 blit ---- - * 低档/高档的完整生命周期(低档先落位→停稳换清晰→拖动取消)在 test_reader_membudget - * 的 B10 用真实 JPEG 夹具覆盖;这里只钉住「分档只给 JPEG」这条策略,防止把 PNG/BMP - * 也拖进未校准的「小图→落框」峰值路径。 */ - { - char book3[MR_PATH_MAX]; - snprintf(book3, sizeof book3, "./mr_perf_books_%ld/v3", (long)getpid()); - int ok3 = mkdir_p(book3) == 0; - for (int i = 0; ok3 && i < 2; i++) { - char p[MR_PATH_MAX]; - snprintf(p, sizeof p, "%s/t%d.bmp", book3, i + 1); - /* 1600×2400 = 3.84MP:量级与真机大页相同,但格式不是 JPEG。 */ - if (write_bmp24(p, 1600, 2400, (unsigned)(i + 41)) != 0) ok3 = 0; - } - if (!ok3) { - printf(" SKIP: tier fixture write failed\n"); - } else { - meow_view_page_pop(); /* 关掉 book2 */ - uint64_t t_open = meow_ui_time_ms(); - mr_reader_open_dir(book3); - uint64_t open_ms = meow_ui_time_ms() - t_open; - CHECK(mr_reader_ui_count() == 2, "tier book pages=%d", mr_reader_ui_count()); - CHECK(open_ms < 500, "open returns without decoding on the UI thread (%llu ms)", - (unsigned long long)open_ms); - pump_ms(600); - draw_once(); - CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, - "non-JPEG big page decodes straight to the target tier (tier=%d)", - mr_reader_ui_focus_tier()); - CHECK(mr_reader_ui_low_decodes() == 0, - "no low tier for PNG/BMP (%d low decodes)", mr_reader_ui_low_decodes()); - int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); - draw_once(); - CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, - "big BMP page draws as native blit (blit +%d, scaled +%d)", - mr_reader_ui_blit_draws() - bl, mr_reader_ui_scaled_draws() - sc); - } - } - meow_view_page_pop(); meow_view_free(reader); meow_view_free(lib); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index de81bbc..ded7039 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -24,10 +24,6 @@ * 按 stb 真实分配模型算峰值,超 3MB 占位 / MemAvailable <6MB 占位。 * 占位可重试,不是永久失败。余量确实充足(MemFree 富余)时允许放宽到 12MB。 * 页数增长不会线性吃内存(常驻只与解码窗大小有关)。 - * 手感(M31):JPEG 大页(≥1MP)先按「显示长边一半」的档位解一版低档并立刻上屏,停稳 - * MR_SHARPEN_MS 后才解目标档位、在同一槽原地替换(两档都落满显示框 → 换图仍是单次 blit, - * 不闪白、不重置缩放/滚动)。拖动/连翻/条漫滚动/跳页在途期间只解低档;高档请求复用 M22 的 - * gen/窗口取消通道作废。每页解码日志尾部带 tier=low|high 便于真机核对「先糊后清」。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page @@ -35,7 +31,7 @@ * 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。 * * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; - * 目标页的低档落位即可跳过去,停稳后原地换清晰;2.5s 超时则降级落位并提示)。 + * 2.5s 超时则降级落位并提示)。 * * 册间衔接:末页前进 → 同父目录下一个含图册(书库自然序)的首页;首页后退 → 上一册末页; * 无相邻册时停住并轻提示(不误触退出)。条漫在条顶/条底继续拉 MR_BOOK_PULL 像素松手触发。 @@ -89,25 +85,6 @@ #define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */ #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ -/* ---- 两级清晰度(M31):先低档秒出,停稳再换高档 ---- - * 大页(真机 1280×2275 单页解码 392ms)打开/翻到时先按「显示长边的一半」解一版低档, - * 落框后立刻上屏;停稳 MR_SHARPEN_MS 后再解目标档位替换同一槽。低档与高档都**精确落到 - * 显示框**,所以换图那一下尺寸不变:仍是一次原生 blit,不闪白、不重置缩放/滚动。 - * 内存(r2,评审 P1-1):高档解码时低档还占着槽,所以「一次操作的总峰值」= - * resident_bytes()(至少含焦点槽低档)+ reader_estimate_peak()(本次解码的峰值); - * 该总峰值受 MR_TIER_TOTAL_CAP 约束(与 page_source 的 12MB 绝对上限同值——常态 3MB - * 只约束 stb 回退路径,后端页连 3MB 都超却已被 M27 放行,拿 3MB 当两档共存上限会让 - * 「先糊后清」在最需要的大页上永远不触发)。超限 → 跳过高档、保留低档(skip=coexist)。 - * 另两条纪律把常驻压到最低(评审 P1-2):分页模式焦点页还是低档时不预取邻居(停稳 150ms 后 - * 就会换清晰,预取来也会被挤掉),高档落位后再按翻页方向补回邻居。 */ -#define MR_SHARPEN_MS 150 /* 停稳多久后才发起高档解码(任务允许 150–300ms;取 150 给「换清晰→补邻居」留出翻页余量) */ -#define MR_TIER_MIN_PIXELS 1000000LL /* 源像素少于此值不分档:解本身不慢,分档只添乱 */ -#define MR_LOW_MIN_LONG 240 /* 低档输出长边下限(再粗就糊到难看) */ -#define MR_LOW_MAX_LONG 480 /* 低档输出长边上限(再细省不了多少解码) */ -#define MR_TIER_TOTAL_CAP (12LL * 1024 * 1024) /* 两档共存:常驻 + 单次解码 的绝对上限 */ - -/* MR_TIER_LOW / MR_TIER_HIGH 定义在 ui/reader/reader.h(宿主测试也要用同一套档位值)。 */ - #define MR_REQ_MAX 8 #define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */ @@ -175,9 +152,6 @@ typedef struct page_slot { meow_img *img; /* 已 downscale 的页图 */ int src_w, src_h; int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */ - unsigned char tier; /* MR_TIER_*:低档过渡图 / 目标档位(M31) */ - unsigned char hi_tried; /* 1=这一槽最近一次「换清晰」失败/跳过(别每 tick 重试; - * 拖动取消时由 settle_reset 复位,停稳后还能再试) */ } page_slot; static page_slot g_slot[MR_SLOTS]; @@ -202,20 +176,15 @@ static page_meta *g_meta; typedef struct decode_plan { int want_w, want_h; int box_w, box_h; - int tier; /* MR_TIER_LOW / MR_TIER_HIGH(M31) */ - int sharpen; /* 1=「停稳换清晰」的追加请求(要算两档共存峰值) */ - long long resident; /* 发起这次请求时已在槽里的字节(主线程快照) */ } decode_plan; static decode_plan g_plan; static int g_plan_page; /* 本次解码的页号(只用于日志) */ static int g_decode_skip; /* reader_load_page 的跳过原因(MR_SKIP_*) */ -static int g_decode_tier; /* reader_load_page 实际生效的档位(低档降为单档时=HIGH) */ static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */ #define MR_SKIP_NONE 0 /* 解出来了 */ #define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */ #define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */ -#define MR_SKIP_COEXIST 3 /* 两档共存总峰值超上限:跳过高档、保留低档 */ static int page_failed(int idx) { @@ -269,9 +238,6 @@ typedef struct page_req { unsigned gen; int want_w, want_h; /* 按哪个显示框解码(主线程算,slot_ready 判据) */ int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */ - unsigned char tier; /* MR_TIER_*(M31:低档过渡 / 目标档位) */ - unsigned char sharpen; /* 1=「停稳后换清晰」的追加请求:动起来就作废 */ - long long resident; /* 发起时的常驻字节(M31 r2:高档要算两档共存峰值) */ } page_req; typedef struct page_res { int idx; @@ -280,8 +246,6 @@ typedef struct page_res { int fail; int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; - unsigned char tier; /* MR_TIER_*:结果按哪一档解出来的 */ - unsigned char sharpen; /* 1=这是一次「停稳换清晰」的结果(落位后要补邻居) */ } page_res; static pthread_t g_worker; @@ -295,29 +259,6 @@ static int g_req_head, g_req_tail, g_req_n; static page_res g_res[MR_RES_MAX]; static int g_res_n; -/* ---- 两级清晰度状态(M31) ---- - * g_sharpen_idx:当前唯一允许解高档的页(-1=拖动/未停稳/无需升级)。worker 在锁内据此 - * 作废「在飞的高档请求」——复用 M22 已有的 gen/窗口取消机制,只是多一条判据。 - * g_move_ms:最近一次「动」的时刻(按下/拖动/吸附动画/条漫滚动/fling/换页); - * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 - * g_step_dir:最近一次翻页/跳页的方向(+1 页号增,-1 页号减);高档落位后按这个方向优先 - * 补邻居(P1-2:不在反方向白解一张马上会被挤掉的图)。 - * g_refill:高档落位 → tick 里补回邻居(换清晰会为省峰值腾掉邻居,不补就每次翻页都 miss)。 */ -static int g_sharpen_idx = -1; -/* 每页是否有请求已排队/正在 worker 手里(去重)。M31 r2(评审 P1-2):只靠队列扫描不够—— - * 请求被 worker 取走后就不在队列里了,窗口刷新会再排一次同一页(重复解码 + 挤掉刚预取的 - * 邻居,正是「每翻一页解 4 次 / 0 命中」的来源)。锁内读写。 */ -static unsigned char g_req_inflight[MR_BOOK_MAX_PAGES]; -static uint64_t g_move_ms; -static int g_sharpen_ms = MR_SHARPEN_MS; -static int g_step_dir = 1; -static int g_refill; -static volatile int g_low_decodes; /* 完成的低档解码次数(宿主测试/日志对照) */ -static volatile int g_high_decodes; /* 完成的目标档位解码次数 */ -static volatile int g_high_canceled; /* 被取消的高档请求数(拖动/换页/停稳失效) */ -static volatile int g_flip_hits; /* 翻页时目标页已在槽(预取命中)次数 */ -static volatile int g_flip_misses; /* 翻页时目标页还没解好(要等解码)次数 */ - /* ---- 节拍/动画 ---- */ static meow_anim *g_ticker; static meow_anim *g_snap; /* 翻页滑动吸附 */ @@ -371,7 +312,6 @@ static page_slot *slot_find(int idx); static void cont_touch(void); static void cont_save_now(void); static int current_page(void); -static int page_needs_tier(int idx); /* ============================================================ */ /* 内存读数与观测(M22) */ @@ -480,64 +420,15 @@ static void plan_box_size(int want_w, int want_h, int *bw, int *bh) /* 固定字段的解码日志:page/src/peak/path 之后是结论,便于 `grep 'reader decode'` * 后按列看曲线。peak = reader_estimate_peak 的预估峰值(字节/kB);path = 实际解码路径 - * (backend = M23 真缩解后端,stb = 回退旧路径);tier = 本次请求的清晰度档位(M31)。 */ + * (backend = M23 真缩解后端,stb = 回退旧路径)。 */ static void log_decode(int page, int src_w, int src_h, long long peak_bytes, long memfree, long memavail, const char *verdict, int step, - int backend, int tier, unsigned long long ms) + int backend, unsigned long long ms) { meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu tier=%s", + "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu", page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, - backend ? "backend" : "stb", step, ms, - tier == MR_TIER_LOW ? "low" : "high"); -} - -/* 低档解码目标长边(M31):显示长边的一半,夹到 [MR_LOW_MIN_LONG, MR_LOW_MAX_LONG]。 - * 只对 JPEG 分档,且必须真的下探一档以上(后端 DCT 阶 n = ceil(8×目标/源长边); - * 低档 n 不更小 = 同样的活,只多一次落框重采样): - * - 「档位」(step)本来就是 JPEG 缩解的概念;PNG/BMP 后端按目标逐行丢行,本来就不慢, - * 而且阅读器的单页峰值模型对非 JPEG 按「输出=显示框」建模——再加一条小图→落框的 - * 路径会让模型低估。分档只留给真机 168–400ms 的 JPEG 大页。 - * 返回 0 = 这页不分档,直接按目标档位解。 */ -static int plan_low_dim(const mr_img_info *in, int box_w, int box_h) -{ - if (!in || in->w <= 0 || in->h <= 0) return 0; - if (in->codec != MR_IMG_CODEC_JPEG) return 0; - if ((long long)in->w * in->h < MR_TIER_MIN_PIXELS) return 0; - int l0 = in->w > in->h ? in->w : in->h; - int bl = box_w > box_h ? box_w : box_h; - if (l0 <= 0 || bl <= 0) return 0; - int lo = bl / 2; - if (lo < MR_LOW_MIN_LONG) lo = MR_LOW_MIN_LONG; - if (lo > MR_LOW_MAX_LONG) lo = MR_LOW_MAX_LONG; - if (lo >= bl) return 0; /* 显示框本来就不大:没有更粗的余地 */ - int nh = (int)((8LL * bl + l0 - 1) / l0); - int nl = (int)((8LL * lo + l0 - 1) / l0); - if (nh < 1) nh = 1; - if (nl < 1) nl = 1; - if (nl >= nh) return 0; /* 同一 DCT 档:低档并不更快 */ - return lo; -} - -/* 按「解码目标长边」跑一次 page_source 的唯一峰值模型(含超预算时继续加粗档位的旧逻辑)。 - * JPEG 档位由目标长边反推,与 meow_img_load_file_step_ex 的 eff 口径一致;模型在 - * 「step>1 且输出≠显示框」时会把落框临时图算进峰值——低档走的正是这条已校准的路径。 */ -static void estimate_for(const mr_img_info *in, int load_dim, int box_w, int box_h, - long mfree, long mavail, mr_decode_decision *dec) -{ - int step = 1; - if (in->codec == MR_IMG_CODEC_JPEG) { - int l0 = in->w > in->h ? in->w : in->h; - step = meow_img_pick_scale(l0, load_dim); - } - for (;;) { - reader_estimate_peak(in, step, box_w, box_h, mfree, mavail, dec); - /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ - if (dec->path == MR_DECODE_PATH_BACKEND || dec->skip == MR_DECODE_OK || - step >= 8 || in->codec != MR_IMG_CODEC_JPEG) - break; - step *= 2; - } + backend ? "backend" : "stb", step, ms); } static meow_img *reader_load_page(const char *path, void *ud, int maxdim) @@ -547,8 +438,8 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) uint64_t t0 = meow_ui_time_ms(); if (!path || !path[0]) return NULL; - /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限 + 档位);worker/主线程 - * 同步兜底路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ + /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限);worker/主线程同步兜底 + * 路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ int want_w = g_plan.want_w, want_h = g_plan.want_h; int box_w = g_plan.box_w, box_h = g_plan.box_h; int page = g_plan_page; @@ -569,49 +460,30 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) * reader_estimate_peak 同时给出「走后端还是 stb」与「是否占位」。后端能接住的页 * (M23,峰值与源尺寸无关)直接放行;只有回退 stb 的页才参与峰值预算/MemAvailable * 门控。模型不再在本文件另写一份。 */ - int tier = g_plan.tier; mr_img_info in; int have = mr_probe_info(path, &in); - int load_dim = dim; /* 本次解码的输出长边(低档 < dim) */ mr_decode_decision dec; memset(&dec, 0, sizeof dec); dec.step = 1; - g_decode_tier = MR_TIER_HIGH; - if (!have) tier = MR_TIER_HIGH; /* 尺寸未知:没有分档依据,按单档解(日志别谎报) */ if (have) { - if (tier == MR_TIER_LOW) { - int lo = plan_low_dim(&in, box_w, box_h); - if (lo > 0 && lo < dim) load_dim = lo; - else tier = MR_TIER_HIGH; /* 这页不值得/无法再粗 → 单档 */ + int step = 1; + if (in.codec == MR_IMG_CODEC_JPEG) { + int l0 = in.w > in.h ? in.w : in.h; + step = meow_img_pick_scale(l0, dim); } - estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); - /* 低档多一次落框重采样,峰值比高档高;门控拒绝时退回单档(高档),别把本来 - * 能显示的页变成「内存不足」占位。非 JPEG 的 stb 回退没有缩解能力(整幅解后 - * 再缩),低档只是白解一遍,同样退回单档。 */ - if (tier == MR_TIER_LOW && - (dec.skip != MR_DECODE_OK || - (dec.path == MR_DECODE_PATH_STB && in.codec != MR_IMG_CODEC_JPEG))) { - tier = MR_TIER_HIGH; - load_dim = dim; - estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); - } - g_decode_tier = tier; /* 实际生效的档位(worker 据此计数/落槽) */ - /* 两档共存(M31 r2 / 评审 P1-1):高档解码时低档仍在槽里显示,所以这次操作的 - * 真实峰值 = 已在槽里的常驻 + 本次解码峰值。M27 的模型只描述后者;这里把前者 - * 加进同一个上限(MR_TIER_TOTAL_CAP),超了就跳过高档、保留低档(页面仍可读)。 */ - if (g_plan.sharpen && dec.skip == MR_DECODE_OK && - g_plan.resident + dec.peak_bytes > MR_TIER_TOTAL_CAP) { - g_decode_skip = MR_SKIP_COEXIST; - log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, "skip=coexist", - dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier, - (unsigned long long)(meow_ui_time_ms() - t0)); - return NULL; + for (;;) { + reader_estimate_peak(&in, step, box_w, box_h, mfree, mavail, &dec); + /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ + if (dec.path == MR_DECODE_PATH_BACKEND || dec.skip == MR_DECODE_OK || + step >= 8 || in.codec != MR_IMG_CODEC_JPEG) + break; + step *= 2; } if (dec.skip != MR_DECODE_OK) { g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, dec.skip == MR_DECODE_SKIP_LOWMEM ? "skip=lowmem" : "skip=peak", - dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier, + dec.step, dec.path == MR_DECODE_PATH_BACKEND, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -623,12 +495,12 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) * 18–36MB)。只有判定为 stb 的页才允许 stb。 */ int allow_stb = !have || dec.path != MR_DECODE_PATH_BACKEND; int used_backend = 0; - meow_img *im = have ? meow_img_load_file_step_ex(path, load_dim, dec.step, allow_stb, + meow_img *im = have ? meow_img_load_file_step_ex(path, dim, dec.step, allow_stb, &used_backend) : meow_img_load_file_dim(path, dim); if (!im) { log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail", - dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, tier, + dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -637,18 +509,17 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } } - /* 3) 精确落到显示框(低档过渡图 / 故意用了粗档位时):结果 = box → 命中页仍是一次原生 - * blit,不会把「先糊后清」变成每帧重采样(M17 的热路径纪律)。低档也落满显示框, - * 所以换高档时尺寸不变:不闪白、不重置缩放/滚动,且两档在槽里是同一张的位置。 + /* 3) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit, + * 不会把内存换来的降级变成每帧重采样(M17 的热路径纪律)。 * 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节, * 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */ - if (have && (tier == MR_TIER_LOW || dec.step > 1) && (in.w >= box_w || in.h >= box_h) && + if (have && dec.step > 1 && (in.w >= box_w || in.h >= box_h) && (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { meow_img *fit = meow_img_resample(im, box_w, box_h); if (fit) { meow_img_free(im); im = fit; } } log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "ok", - dec.step, used_backend, tier, + dec.step, used_backend, (unsigned long long)(meow_ui_time_ms() - t0)); return im; } @@ -809,8 +680,6 @@ static void slot_clear(int i) g_slot[i].idx = -1; g_slot[i].src_w = g_slot[i].src_h = 0; g_slot[i].want_w = g_slot[i].want_h = 0; - g_slot[i].tier = MR_TIER_HIGH; - g_slot[i].hi_tried = 0; } static void slots_free_all(void) @@ -863,11 +732,9 @@ static void slots_reserve(long need_bytes) } } -/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸;低档→高档也走这里)。 - * 返回 1=已放入。want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 - * tier 记下这一槽的清晰度:低档图先顶上,高档图落地时**原地替换**(同一槽、同一尺寸), - * 页面不闪白、不重置缩放/滚动,常驻也不会一页两张。 */ -static int slot_put(int idx, meow_img *img, int want_w, int want_h, int tier) +/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。 + * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */ +static int slot_put(int idx, meow_img *img, int want_w, int want_h) { if (!img || idx < 0) return 0; int i = -1, empty = -1; @@ -899,7 +766,6 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h, int tier) if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */ } - /* 低档 → 高档替换:低档图在这里释放,不会与高档同时常驻。 */ slot_clear(i); g_slot[i].idx = idx; g_slot[i].img = img; @@ -907,7 +773,6 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h, int tier) g_slot[i].src_h = meow_img_h(img); g_slot[i].want_w = want_w; g_slot[i].want_h = want_h; - g_slot[i].tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); return 1; } @@ -970,38 +835,21 @@ static int req_queued(int idx) } /* 预取闸门(任务 4):拖动/惯性期间只解落点页,不做邻居预取;且只在内存确实富裕时预取。 - * MemAvailable 读不到按「不预取」处理(保守)。 - * M31 r2(评审 P1-2):分页模式下焦点页还是低档 → 停稳 150ms 后就要换清晰,而换清晰为了 - * 压低两档共存峰值会腾掉非可见页;此刻预取的邻居正好会被挤掉(白解一次,且换完不补就 - * 每次翻页 miss)。所以「焦点页等着换清晰」期间不预取邻居,等高档落位后由 sharpen_refill() - * 按翻页方向补回来。低档已放弃升级(hi_tried)或焦点已清晰时恢复正常预取。 */ + * MemAvailable 读不到按「不预取」处理(保守)。 */ static int prefetch_allowed(void) { if (g_dragging) return 0; if (g_scroll && meow_scroll_active(g_scroll)) return 0; - if (g_mode == MR_MODE_PAGED) { - int f = focus_page(); - page_slot *s = f >= 0 ? slot_find(f) : NULL; - if (s && s->tier == MR_TIER_LOW && !s->hi_tried && page_needs_tier(f)) return 0; - } long a = mem_avail_kb(); if (a < 0) return 0; return a >= MR_PREFETCH_MIN_KB; } -/* 大页才分档:真机 1280×2275 / 1280×1032 这类页单次解码 168–400ms;小图本来就快, - * 分两档只会多一次解码 + 落框重采样。 */ -static int page_needs_tier(int idx) -{ - int sw = 0, sh = 0; - if (!page_meta_dims(idx, &sw, &sh)) return 0; - return (long long)sw * sh >= MR_TIER_MIN_PIXELS; -} - -/* 排一个解码请求。tier 决定解低档还是目标档位;sharpen=1 表示「停稳后换清晰」的追加 - * 请求——动起来(拖动/翻页/滚动)就由 gen/窗口/sharpen 三条判据一起作废。 */ -static int queue_page_tier(int idx, int tier, int sharpen) +static void queue_page(int idx) { + if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; + /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ + if (slot_ready(idx) || page_blocked(idx)) return; /* 几何在这里(主线程)算好塞进请求:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 */ int want_w = 0, want_h = 0; page_geometry_at(idx, &want_w, &want_h); @@ -1010,13 +858,8 @@ static int queue_page_tier(int idx, int tier, int sharpen) plan_box_size(want_w, want_h, &box_w, &box_h); /* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */ slots_reserve((long)box_w * box_h * 4); - int queued = 0; pthread_mutex_lock(&g_lock); - /* 去重:同一页只要已排队或已交给 worker(g_req_inflight)就不再发。后者必须单列: - * 请求被 worker 取走后就不在队列里了,只看 req_queued 会在每次窗口刷新时重排一页, - * 重复解码还会把刚预取的邻居挤掉(评审 P1-2 的 4 次/页、0 命中)。 */ - int busy = idx >= 0 && idx < MR_BOOK_MAX_PAGES && g_req_inflight[idx]; - if (!g_stop && !busy && !req_queued(idx) && g_req_n < MR_REQ_MAX) { + if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { page_req *r = &g_req[g_req_tail]; r->idx = idx; r->gen = g_gen; @@ -1024,37 +867,11 @@ static int queue_page_tier(int idx, int tier, int sharpen) r->want_h = want_h; r->box_w = box_w; r->box_h = box_h; - r->tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); - r->sharpen = (unsigned char)(sharpen ? 1 : 0); - r->resident = (long long)resident_bytes(); /* 腾完地方之后的常驻快照(两档共存峰值) */ g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; - if (idx >= 0 && idx < MR_BOOK_MAX_PAGES) g_req_inflight[idx] = 1; - queued = 1; pthread_cond_signal(&g_cv); } pthread_mutex_unlock(&g_lock); - return queued; -} - -static void queue_page(int idx) -{ - if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ - if (page_blocked(idx)) return; - page_slot *s = slot_find(idx); - if (g_req_inflight[idx]) return; /* 已排队/在解:别重排(重复解码会挤掉邻居) */ - if (s && slot_ready(idx)) { - /* 已经能显示:只有「低档 → 目标档位」这一种情况还要再解,而且必须是当前焦点页、 - * 已停稳(g_sharpen_idx 由 tier_pump 在去抖窗口后置位)、并且最近一次升级没失败过。 - * hi_tried 只在「解坏了 / 预算跳过」时置位(避免每 tick 重试);拖动取消会复位。 */ - if (s->tier != MR_TIER_LOW || s->hi_tried || g_sharpen_idx != idx || - !page_needs_tier(idx)) - return; - queue_page_tier(idx, MR_TIER_HIGH, 1); - return; - } - queue_page_tier(idx, page_needs_tier(idx) ? MR_TIER_LOW : MR_TIER_HIGH, 0); } static void *reader_worker(void *arg) @@ -1068,26 +885,15 @@ static void *reader_worker(void *arg) req = g_req[g_req_head]; g_req_head = (g_req_head + 1) % MR_REQ_MAX; g_req_n--; - /* 取消(任务 4 / M20 P0-3;M31 扩展):出队后、mr_book_open 前二次判过期——旧册 - * (gen 变)或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 - * 1.75× 解码,全是这种在飞不可取消的请求。锁内判,窗口/sharpen 目标也在锁内写, - * 避免共享状态竞争。 - * M31:拖动/连翻期间只许解低档——「停稳换清晰」的请求一旦动起来就作废(复用同一条 - * stale 判据,不新造取消通道)。 */ + /* 取消(任务 4 / M20 P0-3):出队后、mr_book_open 前二次判过期——旧册(gen 变) + * 或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 1.75× 解码, + * 全是这种在飞不可取消的请求。锁内判,窗口也在锁内写,避免共享状态竞争。 */ int stale = (req.gen != g_gen) || !in_window(req.idx); - int stale_motion = 0; - if (!stale && req.sharpen && req.idx != g_sharpen_idx) { - stale = 1; - stale_motion = 1; - } - if (stale && req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0; pthread_mutex_unlock(&g_lock); if (stale) { - if (stale_motion) g_high_canceled++; meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0 tier=%s", - req.idx, mem_free_kb(), mem_avail_kb(), - req.tier == MR_TIER_LOW ? "low" : "high"); + "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0", + req.idx, mem_free_kb(), mem_avail_kb()); continue; } @@ -1096,19 +902,10 @@ static void *reader_worker(void *arg) g_plan.want_h = req.want_h; g_plan.box_w = req.box_w; g_plan.box_h = req.box_h; - g_plan.tier = req.tier; - g_plan.sharpen = req.sharpen; - g_plan.resident = req.resident; g_plan_page = req.idx; g_decode_skip = MR_SKIP_NONE; - g_decode_tier = MR_TIER_HIGH; meow_img *img = mr_book_open(&g_book, req.idx); g_decode_count++; - /* 档位以解码回调**实际生效**的为准:低档请求遇到非 JPEG/不值得分档的页会降为 - * 单档(reader_load_page 把 g_decode_tier 置回 HIGH),落槽也就不会误标成低档。 */ - int eff_tier = g_decode_tier; - if (eff_tier == MR_TIER_LOW) g_low_decodes++; - else g_high_decodes++; int skip = img ? MR_SKIP_NONE : g_decode_skip; pthread_mutex_lock(&g_lock); @@ -1120,13 +917,9 @@ static void *reader_worker(void *arg) g_res[g_res_n].skip = skip; g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; - g_res[g_res_n].tier = (unsigned char)eff_tier; - g_res[g_res_n].sharpen = req.sharpen; g_res_n++; img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } - /* 本页请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ - if (req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0; pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); } @@ -1155,12 +948,10 @@ static void worker_stop(void) g_res_n = 0; g_req_n = 0; g_req_head = g_req_tail = 0; - memset(g_req_inflight, 0, sizeof g_req_inflight); pthread_mutex_unlock(&g_lock); } -/* 丢弃窗口外的排队请求(主线程调用)。M31:还没出队的「停稳换清晰」请求,一旦动起来 - * (g_sharpen_idx 被清)也在这里作废,不必等 worker 出队才发现。 */ +/* 丢弃窗口外的排队请求(主线程调用)。 */ static void queue_prune(void) { pthread_mutex_lock(&g_lock); @@ -1168,22 +959,11 @@ static void queue_prune(void) int n = 0; for (int i = 0; i < g_req_n; i++) { page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; - int drop = 0; - if (r.gen != g_gen || !in_window(r.idx)) drop = 1; - if (!drop && r.sharpen && r.idx != g_sharpen_idx) { - g_high_canceled++; - drop = 1; - } + if (r.gen != g_gen || !in_window(r.idx)) continue; /* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */ - if (!drop) { - int w = 0, h = 0; - page_geometry_at(r.idx, &w, &h); - if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) drop = 1; - } - if (drop) { - if (r.idx >= 0 && r.idx < MR_BOOK_MAX_PAGES) g_req_inflight[r.idx] = 0; - continue; - } + int w = 0, h = 0; + page_geometry_at(r.idx, &w, &h); + if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) continue; keep[n++] = r; } for (int i = 0; i < n; i++) g_req[i] = keep[i]; @@ -1194,9 +974,7 @@ static void queue_prune(void) } /* 收取后台结果:只留窗口内新页。返回 1=有图落位。 - * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 - * M31:高档(停稳换清晰)失败/跳过时,低档已经在屏幕上——保留低档继续看,不把整页标成 - * 「内存不足」或坏图;低档自己的失败/跳过才走旧语义(那才是真的没图)。 */ + * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 */ static int results_drain(void) { page_res got[MR_RES_MAX]; @@ -1210,29 +988,21 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } - page_slot *low = got[i].tier == MR_TIER_HIGH ? slot_find(got[i].idx) : NULL; - if (low && low->tier != MR_TIER_LOW) low = NULL; /* 已有目标档:不是「低档兜底」 */ if (got[i].skip) { - /* 预算跳过(内存不足/峰值超限/两档共存超上限):可重试,不写永久失败位。 */ - if (low) { low->hi_tried = 1; continue; } /* 低档在屏:降级显示,不占位 */ + /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ page_mark_skipped(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } if (got[i].fail) { - if (low) { low->hi_tried = 1; continue; } page_mark_failed(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } if (in_window(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h, - got[i].tier) && page_visible(got[i].idx)) - changed = 1; + if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h) && + page_visible(got[i].idx)) changed = 1; page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ - /* 换清晰落位:换清晰前为压峰值腾掉了邻居,这里补回(P1-2)。 - * 普通预取/首次解码不腾邻居,不需要补。 */ - if (got[i].sharpen) g_refill = 1; } else { meow_img_free(got[i].img); } @@ -1276,86 +1046,6 @@ static void window_update(void) } } -/* ============================================================ */ -/* 两级清晰度的停稳去抖(M31) */ -/* ------------------------------------------------------------ - * 规则只有三条: - * 1) 拖动 / 吸附动画 / 条漫滚动(含 fling)期间一律不动高档:g_sharpen_idx=-1, - * 在飞/排队的「停稳换清晰」请求按 M22 的取消通道作废;跳页在途时同样不解高档 - * (单 worker 串行,导航优先); - * 2) 停稳 MR_SHARPEN_MS 后,焦点页若是低档,才允许它解目标档位(g_sharpen_idx=focus); - * 3) 每次「动」都重置停稳计时;换页/换几何也会清掉资格(重新计时)。 - * 高档只发给焦点页:单 worker 串行,先保证用户正看的那页清晰,邻居留低档待翻到再说。 */ -static int motion_active(void) -{ - if (g_dragging) return 1; - if (g_snap || g_bounce) return 1; - if (g_scroll && meow_scroll_active(g_scroll)) return 1; - return 0; -} - -/* 「动」了:重置停稳计时,并取消当前的高档资格(排队/在飞的请求随之作废)。 - * 被取消的这一页如果还是低档,把 hi_tried 复位——停稳后还能再试一次。 */ -static void settle_reset(void) -{ - g_move_ms = meow_ui_time_ms(); - if (g_sharpen_idx < 0) return; - page_slot *s = slot_find(g_sharpen_idx); - if (s && s->tier == MR_TIER_LOW) s->hi_tried = 0; - pthread_mutex_lock(&g_lock); - g_sharpen_idx = -1; - pthread_mutex_unlock(&g_lock); - queue_prune(); -} - -static void tier_pump(uint64_t now) -{ - if (g_count <= 0 || g_quitting) return; - if (motion_active()) { - g_move_ms = now; - if (g_sharpen_idx >= 0) settle_reset(); - return; - } - /* 跳页在途:单 worker 串行,「换清晰」是锦上添花,不能插在跳页目标的低档前面—— - * 否则滑块一拖就是几秒(早已过停稳窗口),老页的高档会把跳页落位往后挤几百 ms。 */ - if (mr_jump_active(&g_jump)) { - if (g_sharpen_idx >= 0) settle_reset(); - return; - } - int f = focus_page(); - if (f < 0) return; - page_slot *s = slot_find(f); - if (!s || s->tier != MR_TIER_LOW || s->hi_tried || !page_needs_tier(f)) { - if (g_sharpen_idx == f) { - pthread_mutex_lock(&g_lock); - g_sharpen_idx = -1; - pthread_mutex_unlock(&g_lock); - } - return; - } - if (now - g_move_ms < (uint64_t)g_sharpen_ms) return; /* 还没停稳 */ - if (g_sharpen_idx != f) { - pthread_mutex_lock(&g_lock); - g_sharpen_idx = f; - pthread_mutex_unlock(&g_lock); - } - queue_page(f); /* 幂等:已排队/在飞/已清晰/已试过都直接返回 */ -} - -/* 高档落位后补邻居(M31 r2 / 评审 P1-2)。 - * 换清晰前 slots_reserve 会腾掉非可见页来压两档共存峰值,顺序翻页时被腾掉的正是 - * 「下一页」的预取;不补回来,每次翻页都会 miss(基线 8/8 → 分档后 0/8)。 - * 只补最近一次翻页方向上的邻居:反方向那张在 3MB 常驻上限下放不下(必然被下一次预取 - * 挤掉),白解一次还抬高解码次数;回头看时按需解一版低档——低档本来就是为这个设计的。 */ -static void sharpen_refill(void) -{ - if (g_mode != MR_MODE_PAGED || g_count <= 0 || g_quitting) return; - int f = focus_page(); - if (f < 0) return; - int fwd = f + (g_step_dir >= 0 ? 1 : -1); - if (fwd >= 0 && fwd < g_count && !slot_find(fwd)) queue_page(fwd); -} - /* ============================================================ */ /* 条漫排版 + 滚动会话 */ /* ============================================================ */ @@ -1679,7 +1369,6 @@ static void set_fit(int fit) g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; - settle_reset(); /* 几何变了:停稳计时重来,低档先上屏再换清晰 */ window_update(); update_title(); meow_ui_invalidate(0, 0, g_vw, g_vh); @@ -1707,14 +1396,11 @@ static void pan_by(int dx, int dy) static void commit_page(int target) { - int prev = g_page; target = mr_nav_clamp_page(target, g_count); if (target < 0) return; g_page = target; - if (target != prev) g_step_dir = target > prev ? 1 : -1; /* 高档落位后按这个方向补邻居 */ g_zoom_pct = 100; g_pan_x = g_pan_y = 0; - settle_reset(); /* 换页:停稳计时重来(拖动/连翻期间不解高档) */ window_update(); update_title(); slider_sync(); @@ -1821,13 +1507,11 @@ static void page_flip(int delta) g_flip_t0 = meow_ui_time_ms(); if (slot_find(target)) { - g_flip_hits++; if (start_slide(delta, 0)) meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1); else meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1); } else { - g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); } @@ -1839,10 +1523,8 @@ static void page_flip(int delta) static void jump_land(int page, int timed_out) { if (g_count <= 0) return; - int prev = current_page(); page = mr_nav_clamp_page(page, g_count); if (page < 0) page = 0; - if (page != prev) g_step_dir = page > prev ? 1 : -1; /* 高档落位后按跳页方向补邻居 */ if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { if (g_scroll) meow_scroll_stop(g_scroll); g_woff = mr_strip_offset_for_page(&g_strip, page); @@ -1853,7 +1535,6 @@ static void jump_land(int page, int timed_out) g_pan_x = g_pan_y = 0; } slider_sync(); - settle_reset(); /* 落位:停稳计时重来 */ window_update(); update_title(); cont_touch(); @@ -1940,7 +1621,6 @@ static void mode_set(int mode, int from_page) g_zoom_pct = 100; g_pan_x = g_pan_y = 0; slider_sync(); - settle_reset(); /* 模式/排版几何变了:停稳计时重来 */ window_update(); update_title(); cont_touch(); @@ -1983,11 +1663,6 @@ static void open_book_reset_state(void) mr_jump_cancel(&g_jump); g_toast[0] = '\0'; g_title_page = -1; - g_sharpen_idx = -1; /* 新册:高档资格清零,停稳计时重来 */ - memset(g_req_inflight, 0, sizeof g_req_inflight); - g_move_ms = meow_ui_time_ms(); - g_step_dir = 1; - g_refill = 0; free(g_meta); g_meta = NULL; } @@ -2085,10 +1760,9 @@ static int open_book(const char *dir, int start_page, int mode) g_plan.want_h = wh; g_plan.box_w = bw; g_plan.box_h = bh; - g_plan.tier = MR_TIER_HIGH; /* 没有后台线程:直接解目标档,不分档 */ g_plan_page = g_page; meow_img *first = mr_book_open(&g_book, g_page); - if (first) slot_put(g_page, first, ww, wh, MR_TIER_HIGH); + if (first) slot_put(g_page, first, ww, wh); meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); } slider_build(); @@ -2171,10 +1845,8 @@ static void drag_finish(void) } g_flip_t0 = meow_ui_time_ms(); if (slot_find(target) && start_slide(delta, dx)) { - g_flip_hits++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); } else { - g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); @@ -2204,7 +1876,6 @@ void reader_on_touch_down(int x, int y) { if (!g_ctrl.active || g_quitting) return; if (g_count <= 0) return; - settle_reset(); /* 手一按下去就不算停稳:在飞的高档请求作废 */ g_touch_moved = 0; /* 每次按下都是新手势(chrome 抬手也不残留) */ if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ @@ -2384,7 +2055,6 @@ static void tap_double(int x, int y) g_pan_y = (g_vh / 2 - y) * (z - 100) / 100; clamp_pan(); } - settle_reset(); /* 缩放几何变了:重新计时(低档先上屏) */ window_update(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -2487,15 +2157,6 @@ static void reader_tick(void) /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ if (results_drain() && !g_dragging) invalidate_content(); - /* 高档落位后补回换清晰时为压峰值腾掉的邻居(P1-2:否则顺序翻页每次都 miss)。 */ - if (g_refill) { - g_refill = 0; - if (!g_dragging) sharpen_refill(); - } - - /* 两级清晰度:停稳去抖(拖动/滚动期间只留低档,停稳后才补高档)。 */ - tier_pump(now); - /* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */ if (g_open_err && g_open_retry_at && now >= g_open_retry_at) { g_open_retry_at = 0; @@ -2651,17 +2312,6 @@ static void reader_state_reset(void) memset(&g_plan, 0, sizeof g_plan); g_plan_page = 0; g_decode_skip = MR_SKIP_NONE; - g_decode_tier = MR_TIER_HIGH; - g_sharpen_idx = -1; - memset(g_req_inflight, 0, sizeof g_req_inflight); - g_move_ms = meow_ui_time_ms(); - g_step_dir = 1; - g_refill = 0; - g_low_decodes = 0; - g_high_decodes = 0; - g_high_canceled = 0; - g_flip_hits = 0; - g_flip_misses = 0; } static void reader_page_enter(meow_view *v, void *ctx) @@ -2877,49 +2527,6 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } -/* M31 两级清晰度观察口:焦点页当前档位(-1=无图/0=低档/1=目标档位)、各档解码次数、 - * 被取消的高档请求数、当前允许升级的页(-1=拖动/未停稳/无需升级)。 */ -int mr_reader_ui_focus_tier(void) -{ - page_slot *s = slot_find(focus_page()); - if (!s) return -1; - return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH; -} -int mr_reader_ui_slot_tier(int idx) -{ - page_slot *s = slot_find(idx); - if (!s) return -1; - return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH; -} -long mr_reader_ui_slot_kb(int idx) -{ - page_slot *s = slot_find(idx); - if (!s) return 0; - return (long)s->src_w * s->src_h * 4 / 1024; -} -int mr_reader_ui_low_decodes(void) { return g_low_decodes; } -int mr_reader_ui_high_decodes(void) { return g_high_decodes; } -int mr_reader_ui_high_canceled(void) { return g_high_canceled; } -int mr_reader_ui_sharpen_idx(void) { return g_sharpen_idx; } -/* 翻页命中/未命中:目标页已在槽(= 预取生效,翻页不用等解码)/ 还没解好。 */ -int mr_reader_ui_flip_hits(void) { return g_flip_hits; } -int mr_reader_ui_flip_misses(void) { return g_flip_misses; } -/* 视图位置(M31 r2 评审 P2-2:换清晰不得重置缩放/平移/条漫滚动位置)。 */ -int mr_reader_ui_zoom_pct(void) { return g_zoom_pct; } -int mr_reader_ui_pan_x(void) { return g_pan_x; } -int mr_reader_ui_pan_y(void) { return g_pan_y; } -int mr_reader_ui_woff(void) { return g_woff; } -/* 宿主测试改写「停稳」窗口:0=立刻允许高档(同步验证落位),很大=冻结高档(验证低档先落位)。 */ -void mr_reader_ui_test_set_sharpen_ms(int ms) -{ - g_sharpen_ms = ms < 0 ? 0 : ms; -} -/* 宿主测试:绕过停稳判定强行排一个「换清晰」请求,用于验证拖动中会被取消。 */ -void mr_reader_ui_test_queue_high(int idx) -{ - if (idx < 0 || idx >= g_count) return; - queue_page_tier(idx, MR_TIER_HIGH, 1); -} /* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ void mr_reader_test_toggle_mode(void) { diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index c9c9df1..3ddca22 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -10,11 +10,6 @@ extern "C" { #endif -/* 两级清晰度档位(M31):低档=先上屏的过渡图,高档=停稳后替换的目标档位。 - * 同时供宿主测试断言槽内档位用(mr_reader_ui_slot_tier / mr_reader_ui_focus_tier)。 */ -#define MR_TIER_LOW 0 -#define MR_TIER_HIGH 1 - void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); @@ -56,24 +51,6 @@ long mr_reader_ui_resident_cap_kb(void); /* 常驻上限(kB) */ int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ -/* ---- M31 两级清晰度(先低档秒出,停稳再换档)---- */ -int mr_reader_ui_focus_tier(void); /* 焦点页档位:-1=无图,0=低档过渡图,1=目标档位 */ -int mr_reader_ui_slot_tier(int idx);/* 指定页档位:-1=不在槽内,否则同上 */ -long mr_reader_ui_slot_kb(int idx); /* 指定槽内页图的常驻字节(KB);0=不在槽内 */ -int mr_reader_ui_low_decodes(void); /* 低档解码次数 */ -int mr_reader_ui_high_decodes(void);/* 目标档位解码次数 */ -int mr_reader_ui_high_canceled(void);/* 被取消(拖动/换页/未停稳)的高档请求数 */ -int mr_reader_ui_sharpen_idx(void); /* 当前允许升级的页;-1=拖动/未停稳/无需升级 */ -int mr_reader_ui_flip_hits(void); /* 翻页时目标页已在槽(预取命中,不用等解码)次数 */ -int mr_reader_ui_flip_misses(void); /* 翻页时目标页还没解好(要等解码)次数 */ -int mr_reader_ui_zoom_pct(void); /* 当前缩放档(100=未放大) */ -int mr_reader_ui_pan_x(void); /* 当前平移(px) */ -int mr_reader_ui_pan_y(void); -int mr_reader_ui_woff(void); /* 条漫滚动偏移(px) */ -/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=150ms):0=立刻允许高档,很大=冻结高档。 */ -void mr_reader_ui_test_set_sharpen_ms(int ms); -/* 绕过停稳判定强排一个「换清晰」请求(验证拖动中会被 M22 的取消通道作废)。 */ -void mr_reader_ui_test_queue_high(int idx); int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ /* 直接切模式(绕过顶栏 tap 的命中时序;测试用,产品路径仍走顶栏动作)。 */ From 703602000541621f44feb8be7b60c3ad217a83bf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 18:33:41 +0800 Subject: [PATCH 35/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=99=A8=E9=A1=B5=E6=A7=BD=E6=94=B9=20RGB565=20=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=EF=BC=8C=E9=A1=B5=E5=9B=BE=E5=86=85=E5=AD=98=E5=87=8F?= =?UTF-8?q?=E5=8D=8A=EF=BC=88M32=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页槽从 RGBA 改成「已落框的 RGB565 位图」(2B/px):真机 480×853 页 1599KB → 800KB, 两页常驻 3.2MB → 1.6MB。解码仍在 worker 里产出 RGBA,当场转 565 并释放 RGBA。 - SDK 新增 565 原语(meow_ui_draw.h / meow_ui_gfx.c):meow_gfx_blit_rgb565 命中页每行 一次 memcpy(单次拷贝、无每帧重采样),缩放兜底走 565 最近邻。量化与 back_buf 用同一个 rgb565(),565 屏上落下的像素与旧 RGBA 路径逐像素一致(不引入色偏/新增色带)。 - 预算按 565 重算:单页像素上限沿用旧 3MB@RGBA 的同几何口径(=1.5MB@565), MR_RESIDENT_CAP = 3 槽 + 1 张在途 = 3.2MB;reader_estimate_peak 新增 convert_bytes (落槽暂存 2B/px)与 peak_bytes 一起参与门控,避免实测超预算。 - 结果队列(已解码、未落槽)字节计入总预算(pending_bytes),不再「偷吃」最多 2 页。 - CBZ meta() 改读条目 4KB 前缀(mz_read_prefix;deflate 解到 4KB 即停,输入缓冲走栈), 500 页排版不再触发 500 次全量解压。 - 测试:B3/B8 的 565 常驻与在途预算断言、CBZ 500 页 stored/deflate 前后对比、 P12 565 与 RGBA 路径的逐像素颜色对比(量化误差/RMSE/灰阶梯)。 --- sdk/include/meow_ui_draw.h | 13 ++ sdk/meow_ui_gfx.c | 99 +++++++++ tuku/logic/reader/archive_source.c | 49 +++-- tuku/logic/reader/page_source.c | 13 +- tuku/logic/reader/page_source.h | 9 +- tuku/logic/zip/zip.c | 68 +++++- tuku/logic/zip/zip.h | 9 + tuku/test/test_reader_membudget.c | 329 +++++++++++++++++++++++++++-- tuku/test/test_reader_perf.c | 173 ++++++++++++++- tuku/ui/reader/reader.c | 245 +++++++++++++++------ tuku/ui/reader/reader.h | 14 +- 11 files changed, 910 insertions(+), 111 deletions(-) diff --git a/sdk/include/meow_ui_draw.h b/sdk/include/meow_ui_draw.h index e6ff303..2c7e5cf 100644 --- a/sdk/include/meow_ui_draw.h +++ b/sdk/include/meow_ui_draw.h @@ -50,6 +50,19 @@ void meow_ui_flush_region(int x, int y, int w, int h); /* 只刷新指定区 * /* 半透明(blend565:前景按 alpha 与背景混叠) */ void meow_ui_fill_rect_alpha(int x, int y, int w, int h, uint32_t color, int alpha); /* 0-255 */ +/* ---- RGB565 页图(M32)---- + * 页槽常驻 RGB565(2 字节/像素):真机 480×853 页 1599KB → 800KB。 + * 量化与 back_buf 用同一个 565 转换(逐位截断),565 屏上最终像素与 RGBA blit + * 路径逐像素一致——不引入色偏、不会比现在多出色带。 + * 命中页(页图尺寸 == 显示尺寸)走 meow_gfx_blit_rgb565:每行一次拷贝,无重采样。 */ +uint16_t meow_gfx_rgb565(uint32_t rgb); /* 24-bit RGB → 565(与 back_buf 同式) */ +void meow_gfx_rgba_to_rgb565(const uint8_t *rgba, uint16_t *out, int npx); /* 不透明像素 */ +/* 原生尺寸 blit(不透明):16bpp back_buf 上是每行一次 memcpy。 */ +void meow_gfx_blit_rgb565(int x, int y, int w, int h, const uint16_t *px); +/* 最近邻缩放 blit(几何不符时的兜底;采样点与 meow_img_resample 同式)。 */ +void meow_gfx_blit_rgb565_scaled(int x, int y, int w, int h, + int sw, int sh, const uint16_t *px); + /* ============================================================ */ /* §7 动画/时间(B1 最小可用) */ /* ============================================================ */ diff --git a/sdk/meow_ui_gfx.c b/sdk/meow_ui_gfx.c index 8418b0d..76c1d2b 100644 --- a/sdk/meow_ui_gfx.c +++ b/sdk/meow_ui_gfx.c @@ -856,6 +856,105 @@ void meow_gfx_blit_rgba(int x, int y, int w, int h, const uint8_t *rgba, int alp dirty_add(x1, y1, x2 - x1, y2 - y1); } +/* ================= RGB565 页图(M32) ================= + * 阅读器页槽常驻 RGB565(2B/px)而不是 RGBA:真机 480×853 页 1599KB → 800KB。 + * 量化与 back_buf 用同一个 rgb565()(逐位截断),所以 565 屏上落下的像素与旧的 + * RGBA blit 路径**逐像素一致**——不引入色偏,也不会比现在多出色带。 + * 命中页(解码/落框尺寸 == 显示尺寸)走 meow_gfx_blit_rgb565:每行一次 memcpy, + * 单次拷贝、没有每帧重采样。alpha 按不透明处理(页图没有透明通道)。 */ +uint16_t meow_gfx_rgb565(uint32_t rgb) +{ + return rgb565(rgb); +} + +void meow_gfx_rgba_to_rgb565(const uint8_t *rgba, uint16_t *out, int npx) +{ + if (!rgba || !out || npx <= 0) return; + for (int i = 0; i < npx; i++) { + const uint8_t *p = rgba + (size_t)i * 4; + out[i] = rgb565(((uint32_t)p[0] << 16) | ((uint32_t)p[1] << 8) | (uint32_t)p[2]); + } +} + +/* 目标矩形与 clip 求交;返回 1=还有可画区域。 */ +static int blit565_clip(int x, int y, int w, int h, int *x1, int *y1, int *x2, int *y2) +{ + if (!g_init_ok || w <= 0 || h <= 0) return 0; + *x1 = x; *y1 = y; *x2 = x + w; *y2 = y + h; + if (*x1 < 0) *x1 = 0; + if (*y1 < 0) *y1 = 0; + if (*x2 > g_xres) *x2 = g_xres; + if (*y2 > g_yres) *y2 = g_yres; + if (g_clip_n > 0) { + int *c = g_clip_stack[g_clip_n - 1]; + if (*x1 < c[0]) *x1 = c[0]; + if (*y1 < c[1]) *y1 = c[1]; + if (*x2 > c[2]) *x2 = c[2]; + if (*y2 > c[3]) *y2 = c[3]; + } + return *x2 > *x1 && *y2 > *y1; +} + +void meow_gfx_blit_rgb565(int x, int y, int w, int h, const uint16_t *px) +{ + if (!px) return; + int x1, y1, x2, y2; + if (!blit565_clip(x, y, w, h, &x1, &y1, &x2, &y2)) return; + int n = x2 - x1; + for (int py = y1; py < y2; py++) { + const uint16_t *src = px + (size_t)(py - y) * w + (x1 - x); + unsigned char *dst = g_fb + (size_t)py * g_line + (size_t)x1 * (size_t)(g_bpp / 8); + if (g_bpp == 16) { + memcpy(dst, src, (size_t)n * 2); /* 命中页:每行一次拷贝 */ + } else { + uint32_t *d32 = (uint32_t *)dst; + for (int i = 0; i < n; i++) { + uint16_t c = src[i]; + d32[i] = ((uint32_t)ex5((c >> 11) & 0x1F) << 24) | + ((uint32_t)ex6((c >> 5) & 0x3F) << 16) | + ((uint32_t)ex5(c & 0x1F) << 8) | 0xFFu; + } + } + } + dirty_add(x1, y1, n, y2 - y1); +} + +/* 最近邻缩放(仅几何不符时的兜底路径;采样点与 meow_img_resample 同式)。 + * 命中页不会走到这里——那是热路径纪律(M17/M32)。 */ +void meow_gfx_blit_rgb565_scaled(int x, int y, int w, int h, + int sw, int sh, const uint16_t *px) +{ + if (!px || sw <= 0 || sh <= 0) return; + int x1, y1, x2, y2; + if (!blit565_clip(x, y, w, h, &x1, &y1, &x2, &y2)) return; + int n = x2 - x1; + for (int py = y1; py < y2; py++) { + int sy = (int)((long long)(py - y) * sh / h); + if (sy >= sh) sy = sh - 1; + const uint16_t *row = px + (size_t)sy * sw; + unsigned char *dst = g_fb + (size_t)py * g_line + (size_t)x1 * (size_t)(g_bpp / 8); + if (g_bpp == 16) { + uint16_t *d16 = (uint16_t *)dst; + for (int i = 0; i < n; i++) { + int sx = (int)((long long)(x1 + i - x) * sw / w); + if (sx >= sw) sx = sw - 1; + d16[i] = row[sx]; + } + } else { + uint32_t *d32 = (uint32_t *)dst; + for (int i = 0; i < n; i++) { + int sx = (int)((long long)(x1 + i - x) * sw / w); + if (sx >= sw) sx = sw - 1; + uint16_t c = row[sx]; + d32[i] = ((uint32_t)ex5((c >> 11) & 0x1F) << 24) | + ((uint32_t)ex6((c >> 5) & 0x3F) << 16) | + ((uint32_t)ex5(c & 0x1F) << 8) | 0xFFu; + } + } + } + dirty_add(x1, y1, n, y2 - y1); +} + /* ================= present / 上屏 ================= */ static void dump_ppm(void) { diff --git a/tuku/logic/reader/archive_source.c b/tuku/logic/reader/archive_source.c index 159e891..7d29ecc 100644 --- a/tuku/logic/reader/archive_source.c +++ b/tuku/logic/reader/archive_source.c @@ -4,7 +4,8 @@ * - 页表在 init 时一次建好(mz_open 只读 central directory;只看名字,不解压); * - open(i) 才解压单条目:mz_read_alloc → page_cache 落盘 → 注入的解码回调按路径解码。 * 因此翻页/条漫两套消费代码与目录册完全一致(meta 走原始像素尺寸,open 走解码); - * - meta(i) 按需解压该页到内存只读图片头(不落缓存),结果缓存在 meta_w/meta_h; + * - meta(i) 只读该条目的**前 4KB**(mz_read_prefix)取图片头(不落缓存),结果缓存在 + * meta_w/meta_h;M32 前是整条目解压,500 页排版 = 500 次全量解压; * - 单个 mr_zip_book 的 mz_read* 由自带的互斥量串行化(解码线程与主线程 meta 会碰面)。 * * 线程:count/page_name 只读页表,可任意线程;open 可由解码线程调用;meta 按 M6 @@ -269,7 +270,38 @@ static int zip_ops_count(void *ctx) return zb ? zb->count : 0; } -/* meta 按需解压头探测;结果缓存,每页只解压一次。主线程调用(M6 用法)。 */ +/* meta 只读条目**前缀**(≤4KB)取图片头(M32):改前是 mz_read_alloc 整条目解压后再探 + * (500 页排版 = 500 次全量解压)。PNG IHDR 33 字节、JPEG SOF 在头几百字节、BMP 在 + * 54 字节 + 调色板内,4KB 足够;前缀探测失败且确实被截断时才回退整条目读(罕见/病态文件)。 */ +#define MR_ZIP_META_PREFIX 4096 + +static int zip_meta_probe_prefix(mr_zip_book *zb, const mz_entry *e, int *w, int *h) +{ + unsigned char head[MR_ZIP_META_PREFIX]; + size_t head_len = 0; + pthread_mutex_lock((pthread_mutex_t *)zb->lock); + int rc = mz_read_prefix((mz_archive *)zb->arch, e, head, sizeof head, &head_len); + pthread_mutex_unlock((pthread_mutex_t *)zb->lock); + if (rc != MZ_OK) return 0; + if (mr_probe_size_mem(head, head_len, w, h)) return 1; + if (head_len < sizeof head) return 0; /* 整个条目都读到了还探不出 → 坏头 */ + + /* 前缀被截断且探不出尺寸(病态长的头部):回退整条目读,保持旧语义。 */ + void *buf = NULL; + size_t len = 0; + pthread_mutex_lock((pthread_mutex_t *)zb->lock); + rc = mz_read_alloc((mz_archive *)zb->arch, e, &buf, &len); + pthread_mutex_unlock((pthread_mutex_t *)zb->lock); + if (rc != MZ_OK || !buf) { + free(buf); + return 0; + } + int known = mr_probe_size_mem(buf, len, w, h); + free(buf); + return known; +} + +/* meta 按需探测图片头;结果缓存,每页只读一次(前缀 4KB,不再整页解压)。主线程调用(M6 用法)。 */ static int zip_ops_meta(void *ctx, int index, int *w, int *h) { mr_zip_book *zb = (mr_zip_book *)ctx; @@ -297,19 +329,8 @@ static int zip_ops_meta(void *ctx, int index, int *w, int *h) const mz_entry *e = page_entry(zb, index); if (!e) return 0; - pthread_mutex_lock((pthread_mutex_t *)zb->lock); - void *buf = NULL; - size_t len = 0; - int rc = mz_read_alloc((mz_archive *)zb->arch, e, &buf, &len); - pthread_mutex_unlock((pthread_mutex_t *)zb->lock); - if (rc != MZ_OK || !buf) { - free(buf); - return 0; - } int pw = 0, ph = 0; - int known = mr_probe_size_mem(buf, len, &pw, &ph); - free(buf); - if (!known) return 0; + if (!zip_meta_probe_prefix(zb, e, &pw, &ph)) return 0; zb->meta_w[index] = pw; zb->meta_h[index] = ph; diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c index bed0546..45f6ec2 100644 --- a/tuku/logic/reader/page_source.c +++ b/tuku/logic/reader/page_source.c @@ -425,6 +425,10 @@ int mr_probe_size(const char *path, int *w, int *h) * 与源图分辨率/压缩体积无关;命中即放行(不做内存门控)。 * stb(回退):M22 的真实分配模型(planes + 抽取输出 + 精修/落框临时图)。 * 只有这类页参与 3MB 峰值预算与 MemAvailable < 6MB 门控。 + * M32(页槽改 RGB565):解码仍产出 RGBA 的 meow_img,阅读器把它转成 565 落槽再释放 RGBA。 + * 这笔「落槽暂存」= 2B/px(转换时 RGBA 与 565 同时存活)单独记在 convert_bytes,与 + * peak_bytes 相加才是「一次取页」的真实内存;stb 门控与阅读器日志都按两者之和算。 + * 模型本身(RGBA 解码分配)不变——变的只是每页常驻从 4B/px 降到 2B/px。 * ============================================================ */ #define MR_PEAK_CAP_BYTES (3LL * 1024 * 1024) /* 常态单次解码峰值上限 */ #define MR_PEAK_HARD_BYTES (12LL * 1024 * 1024) /* MemFree 富余时的绝对上限 */ @@ -647,6 +651,7 @@ void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, out->step = 1; out->tw = out->th = 0; out->peak_bytes = 0; + out->convert_bytes = 0; out->budget_bytes = reader_peak_budget_bytes(memfree_kb); if (!in || in->w <= 0 || in->h <= 0 || box_w <= 0 || box_h <= 0) return; @@ -665,9 +670,10 @@ void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, out->path = MR_DECODE_PATH_BACKEND; out->peak_bytes = mr_backend_peak_bytes(in, eff, step, box_w, box_h, &out->tw, &out->th); + out->convert_bytes = (long long)out->tw * out->th * 2; /* 565 落槽暂存(M32) */ if (memavail_kb >= 0 && memavail_kb < MR_MEM_FLOOR_KB) out->skip = MR_DECODE_SKIP_LOWMEM; - else if (out->peak_bytes > MR_PEAK_HARD_BYTES) + else if (out->peak_bytes + out->convert_bytes > MR_PEAK_HARD_BYTES) out->skip = MR_DECODE_SKIP_PEAK; else out->skip = MR_DECODE_OK; @@ -678,10 +684,11 @@ void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, if (longside <= dim) { out->tw = in->w; out->th = in->h; } else mr_target_dims(in->w, in->h, dim, &out->tw, &out->th); out->peak_bytes = mr_stb_peak_bytes(in, step, box_w, box_h); + out->convert_bytes = (long long)out->tw * out->th * 2; /* 565 落槽暂存(M32) */ if (memavail_kb >= 0 && memavail_kb < MR_MEM_FLOOR_KB) out->skip = MR_DECODE_SKIP_LOWMEM; - else if (out->peak_bytes > out->budget_bytes) - out->skip = MR_DECODE_SKIP_PEAK; + else if (out->peak_bytes + out->convert_bytes > out->budget_bytes) + out->skip = MR_DECODE_SKIP_PEAK; /* 565 暂存也占内存:门控按「解码+落槽」总量算 */ else out->skip = MR_DECODE_OK; } diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h index 024a959..60a5fa3 100644 --- a/tuku/logic/reader/page_source.h +++ b/tuku/logic/reader/page_source.h @@ -156,6 +156,13 @@ typedef struct mr_decode_decision { int tw, th; /* 预估最终产出尺寸(含阅读器落框精修) */ long long peak_bytes; /* 预估峰值(字节,含固定开销) */ long long budget_bytes; /* 本次预算(字节;SKIP_PEAK 的比较基准) */ + /* ---- 追加(M32):页槽改 RGB565 后的「落槽暂存」---- + * 解码仍产出 RGBA 的 meow_img;阅读器把它一次性转成 565 再释放 RGBA,转换期间 + * RGBA(4B/px)+ 目标 565(2B/px)同时存在。这笔暂存(= 2×tw×th)与解码峰值不同时 + * 发生(解码中间面此时已释放),但同属「一次取页操作」的内存,必须一起进预算: + * page_source 的门控按 peak_bytes + convert_bytes 比较,阅读器也按两者之和做日志/断言 + * (与 peak_bytes 分开,保持解码模型的原有口径)。 */ + long long convert_bytes; } mr_decode_decision; void reader_estimate_peak(const mr_img_info *in, int step, int box_w, int box_h, @@ -185,7 +192,7 @@ typedef struct mr_zip_book { void *arch; /* mz_archive*(zip 层句柄;不对外暴露) */ void *lock; /* pthread_mutex_t*:mz_read* 串行化(open/meta 共用) */ int *order; /* 页 i → zip 条目下标(堆分配) */ - int *meta_w; /* 页原始宽/高(按需解压头探测;NULL=未探测) */ + int *meta_w; /* 页原始宽/高(按需读条目 4KB 前缀探测;NULL=未探测) */ int *meta_h; int count; int maxdim; diff --git a/tuku/logic/zip/zip.c b/tuku/logic/zip/zip.c index dd60b39..961a019 100644 --- a/tuku/logic/zip/zip.c +++ b/tuku/logic/zip/zip.c @@ -18,6 +18,7 @@ #define MZ_EOCD_MIN 22u #define MZ_COMMENT_MAX 65535u #define MZ_IN_CHUNK 65536u +#define MZ_PREFIX_IN_CHUNK 4096u /* prefix 读的栈缓冲(M32,见 inflate_entry) */ #define MZ_CRC_CHUNK (1u << 24) struct mz_archive { @@ -322,15 +323,21 @@ static uint32_t crc_buf(const uint8_t *p, size_t n) } /* 从 data_off 流式 inflate comp_size 字节到 buf(容量 buf_cap = 声明解压尺寸)。 - * 输出多于声明尺寸或流提前结束都算 FORMAT/TRUNCATED,不整块读取压缩数据。 */ + * 输出多于声明尺寸或流提前结束都算 FORMAT/TRUNCATED,不整块读取压缩数据。 + * prefix=1(M32):装满 buf_cap 就停——只取前缀,不把整条目解压完;此时不判 CRC + * (调用方没读完整条目,见 mz_read_prefix)。prefix=0 的语义与旧实现逐字一致。 */ static int inflate_entry(FILE *fp, uint64_t data_off, uint64_t comp_size, - uint8_t *buf, size_t buf_cap, size_t *out_len) + uint8_t *buf, size_t buf_cap, size_t *out_len, int prefix) { z_stream z; memset(&z, 0, sizeof z); if (inflateInit2(&z, -MAX_WBITS) != Z_OK) return MZ_ERR_MEM; - uint8_t *in = malloc(MZ_IN_CHUNK); + /* prefix 模式最多只要 buf_cap 字节(4KB 文件头),输入侧用 4KB 栈缓冲: + * 不为一次前缀读分配 64KB(500 页排版 = 500 次 64KB 分配/释放)。 */ + uint8_t in_stack[MZ_PREFIX_IN_CHUNK]; + size_t in_chunk = prefix ? sizeof in_stack : MZ_IN_CHUNK; + uint8_t *in = prefix ? in_stack : (uint8_t *)malloc(MZ_IN_CHUNK); if (!in) { inflateEnd(&z); return MZ_ERR_MEM; @@ -343,13 +350,14 @@ static int inflate_entry(FILE *fp, uint64_t data_off, uint64_t comp_size, int stalls = 0; while (rc == MZ_OK) { + if (prefix && produced == buf_cap) break; /* 前缀够了:不再解压 */ if (z.avail_in == 0) { if (consumed >= comp_size) { rc = MZ_ERR_TRUNCATED; break; } uint64_t remain = comp_size - consumed; - size_t want = remain > MZ_IN_CHUNK ? MZ_IN_CHUNK : (size_t)remain; + size_t want = remain > in_chunk ? in_chunk : (size_t)remain; rc = read_exact(fp, in, want); if (rc != MZ_OK) break; z.next_in = in; @@ -399,7 +407,7 @@ static int inflate_entry(FILE *fp, uint64_t data_off, uint64_t comp_size, } inflateEnd(&z); - free(in); + if (!prefix) free(in); if (rc == MZ_OK) *out_len = produced; return rc; } @@ -442,7 +450,7 @@ int mz_read(mz_archive *z, const mz_entry *e, void *buf, size_t buf_size, if (crc_buf((const uint8_t *)buf, need) != e->crc) return MZ_ERR_CRC; } else { size_t made = 0; - rc = inflate_entry(z->fp, data_off, e->comp_size, (uint8_t *)buf, need, &made); + rc = inflate_entry(z->fp, data_off, e->comp_size, (uint8_t *)buf, need, &made, 0); if (rc != MZ_OK) return rc; if (made != need) return MZ_ERR_FORMAT; if (crc_buf((const uint8_t *)buf, made) != e->crc) return MZ_ERR_CRC; @@ -476,6 +484,54 @@ int mz_read_alloc(mz_archive *z, const mz_entry *e, void **out_buf, return MZ_OK; } +int mz_read_prefix(mz_archive *z, const mz_entry *e, void *buf, size_t buf_size, + size_t *out_size) +{ + if (out_size) *out_size = 0; + if (!z || !z->fp || !e) return MZ_ERR_ARG; + if (!buf && buf_size > 0) return MZ_ERR_ARG; + if (buf_size == 0) return MZ_OK; + if (e->flags & 1u) return MZ_ERR_UNSUPPORTED; + if (e->method != 0 && e->method != 8) return MZ_ERR_UNSUPPORTED; + if (e->uncomp_size > (uint64_t)SIZE_MAX) return MZ_ERR_UNSUPPORTED; + + /* 与 mz_read 同一套局部头/数据范围校验(结构非法一律拒绝)。 */ + uint8_t lh[MZ_LOCAL_MIN]; + int rc = read_at(z->fp, e->local_offset, lh, sizeof lh); + if (rc != MZ_OK) return rc; + if (rd32(lh) != MZ_SIG_LOCAL) return MZ_ERR_FORMAT; + if (rd16(lh + 8) != e->method) return MZ_ERR_FORMAT; + + uint64_t data_off = 0; + uint64_t data_end = 0; + if (!u64_add(e->local_offset, MZ_LOCAL_MIN, &data_off) || + !u64_add(data_off, rd16(lh + 26), &data_off) || + !u64_add(data_off, rd16(lh + 28), &data_off) || + !u64_add(data_off, e->comp_size, &data_end) || + data_end > z->file_size) { + return MZ_ERR_TRUNCATED; + } + + size_t want = buf_size; + if (e->uncomp_size < (uint64_t)want) want = (size_t)e->uncomp_size; + + if (e->method == 0) { + if (e->comp_size != e->uncomp_size) return MZ_ERR_FORMAT; + if (want > 0) { + rc = read_at(z->fp, data_off, buf, want); + if (rc != MZ_OK) return rc; + } + } else { + size_t made = 0; + rc = inflate_entry(z->fp, data_off, e->comp_size, (uint8_t *)buf, want, &made, 1); + if (rc != MZ_OK) return rc; + if (made != want) return MZ_ERR_FORMAT; /* 声明尺寸内提前结束 = 坏流 */ + } + + if (out_size) *out_size = want; + return MZ_OK; +} + const char *mz_strerror(int err) { switch (err) { diff --git a/tuku/logic/zip/zip.h b/tuku/logic/zip/zip.h index 845884e..1e1a61b 100644 --- a/tuku/logic/zip/zip.h +++ b/tuku/logic/zip/zip.h @@ -81,6 +81,15 @@ int mz_read(mz_archive *z, const mz_entry *e, void *buf, size_t buf_size, size_t * 成功时 *out_buf 由调用方 free。 */ int mz_read_alloc(mz_archive *z, const mz_entry *e, void **out_buf, size_t *out_size); +/* 只解压条目**前缀**到 buf(buf_size = 容量):stored 直接读前 buf_size 字节, + * deflate 在 inflate 出 buf_size 字节后即停(不整条目解压)。 + * *out_size = 实际写入字节数(= min(buf_size, uncomp_size))。 + * 用途:CBZ 排版只看图片头取宽高(PNG IHDR 33 字节 / JPEG SOF / BMP 头都在前 4KB)。 + * 注意:没有读完整条目,因此**不校验整段 CRC**(只校验结构/长度),不要用它取内容。 + * 出错语义与 mz_read 相同(越界/坏流/加密/zip64 一律返回错误码)。 */ +int mz_read_prefix(mz_archive *z, const mz_entry *e, void *buf, size_t buf_size, + size_t *out_size); + const char *mz_strerror(int err); #endif /* TUKU_LOGIC_ZIP_ZIP_H */ diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index e83cae6..48cafa2 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -31,6 +31,7 @@ #include "meow_ui_internal.h" #include "logic/reader/nav.h" #include "logic/reader/page_source.h" +#include "logic/zip/zip.h" /* M32:CBZ 排版读数的对照(mz_read_alloc = 旧路径) */ #include "stb_image.h" /* stbi_load_scaled:两条路径输出尺寸一致性对照 */ #include @@ -39,6 +40,7 @@ #include #include #include +#include /* M32:CBZ 夹具的 deflate 条目 */ /* ------------------------------------------------------------------ */ /* 分配打点:--wrap malloc/free 家族(与 sdk/test_meow_img_budget.c 同法)*/ @@ -313,6 +315,108 @@ static int write_png_rgb(const char *path, int w, int h, unsigned seed) return ok ? 0 : -1; } +/* 最小 CBZ 写入器:把同一个源文件写成 count 个 pNNN.bmp 条目。 + * deflate=0:stored(未压缩,用来量「整页解压」的分配峰值);deflate=1:method 8(真实 + * CBZ 的形态,用来量前缀读省下的 inflate CPU/字节)。 */ +static int write_zip_cbz(const char *zip_path, const char *src, int count, int deflate_it) +{ + FILE *in = fopen(src, "rb"); + if (!in) return -1; + if (fseek(in, 0, SEEK_END) != 0) { fclose(in); return -1; } + long sz = ftell(in); + if (sz <= 0 || fseek(in, 0, SEEK_SET) != 0) { fclose(in); return -1; } + unsigned char *data = (unsigned char *)malloc((size_t)sz); + if (!data || fread(data, 1, (size_t)sz, in) != (size_t)sz) { + free(data); + fclose(in); + return -1; + } + fclose(in); + unsigned crc = crc32_buf(data, (size_t)sz); + + /* 可选 deflate(raw,-15 窗口,与 zip 一致)。 */ + unsigned char *packed = NULL; + size_t packed_len = (size_t)sz; + if (deflate_it) { + z_stream zs; + memset(&zs, 0, sizeof zs); + if (deflateInit2(&zs, 1, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) { + free(data); + return -1; + } + size_t cap = (size_t)deflateBound(&zs, (uLong)sz); + packed = (unsigned char *)malloc(cap); + if (!packed) { deflateEnd(&zs); free(data); return -1; } + zs.next_in = data; + zs.avail_in = (uInt)sz; + zs.next_out = packed; + zs.avail_out = (uInt)cap; + int zr = deflate(&zs, Z_FINISH); + packed_len = cap - zs.avail_out; + deflateEnd(&zs); + if (zr != Z_STREAM_END) { free(packed); free(data); return -1; } + } + const unsigned char *body = deflate_it ? packed : data; + unsigned body_len = (unsigned)packed_len; + unsigned method = deflate_it ? 8u : 0u; + + FILE *f = fopen(zip_path, "wb"); + if (!f) { free(packed); free(data); return -1; } + unsigned *offs = (unsigned *)calloc((size_t)count, sizeof *offs); + if (!offs) { free(packed); free(data); fclose(f); return -1; } + + int ok = 1; + unsigned off = 0; + for (int i = 0; i < count && ok; i++) { + char name[32]; + int nl = snprintf(name, sizeof name, "p%03d.bmp", i); + unsigned char lh[30] = { 0 }; + put32(lh, 0x04034b50u); + put16(lh + 4, 20); /* version needed */ + put16(lh + 8, (unsigned short)method); /* stored / deflate */ + put32(lh + 14, crc); + put32(lh + 18, body_len); /* comp size */ + put32(lh + 22, (unsigned)sz); /* uncomp size */ + put16(lh + 26, (unsigned short)nl); + offs[i] = off; + if (fwrite(lh, 1, sizeof lh, f) != sizeof lh) ok = 0; + if (ok && fwrite(name, 1, (size_t)nl, f) != (size_t)nl) ok = 0; + if (ok && fwrite(body, 1, body_len, f) != body_len) ok = 0; + off += (unsigned)sizeof lh + (unsigned)nl + body_len; + } + unsigned cd_off = off; + for (int i = 0; i < count && ok; i++) { + char name[32]; + int nl = snprintf(name, sizeof name, "p%03d.bmp", i); + unsigned char cd[46] = { 0 }; + put32(cd, 0x02014b50u); + put16(cd + 4, 20); /* version made by */ + put16(cd + 6, 20); /* version needed */ + put16(cd + 10, (unsigned short)method); + put32(cd + 16, crc); + put32(cd + 20, body_len); + put32(cd + 24, (unsigned)sz); + put16(cd + 28, (unsigned short)nl); + put32(cd + 42, offs[i]); + if (fwrite(cd, 1, sizeof cd, f) != sizeof cd) ok = 0; + if (ok && fwrite(name, 1, (size_t)nl, f) != (size_t)nl) ok = 0; + off += (unsigned)sizeof cd + (unsigned)nl; + } + unsigned char eocd[22] = { 0 }; + put32(eocd, 0x06054b50u); + put16(eocd + 8, (unsigned short)count); + put16(eocd + 10, (unsigned short)count); + put32(eocd + 12, off - cd_off); + put32(eocd + 16, cd_off); + if (ok && fwrite(eocd, 1, sizeof eocd, f) != sizeof eocd) ok = 0; + + free(offs); + free(packed); + free(data); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + static int mkdir_p(const char *path) { char tmp[MR_PATH_MAX]; @@ -414,6 +518,39 @@ static size_t measure_page_decode(const char *path, const mr_img_info *in, int s return peak; } +/* M32:与阅读器 worker 完全同路径的「一次取页」——解码 → 落框 → 转 RGB565 → 释放 RGBA。 + * 返回这段操作的真实分配峰值,用来校验「解码峰值 peak_bytes + 落槽暂存 convert_bytes」 + * 这一对预算口径(转换期间 RGBA 与 565 同时存活,正是 convert_bytes 建模的那笔内存)。 */ +static size_t measure_page_565(const char *path, const mr_img_info *in, int step, + int allow_stb, int box_w, int box_h, int *ow, int *oh) +{ + int dim = box_w > box_h ? box_w : box_h; + size_t base = g_live; + peak_reset(); + int used = 0; + meow_img *im = meow_img_load_file_step_ex(path, dim, step, allow_stb, &used); + if (im && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { + meow_img *small = meow_img_downscale(im, dim); + if (small) { meow_img_free(im); im = small; } + } + if (im && in && step > 1 && (in->w >= box_w || in->h >= box_h) && + (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { + meow_img *fit = meow_img_resample(im, box_w, box_h); + if (fit) { meow_img_free(im); im = fit; } + } + if (im) { + int w = meow_img_w(im), h = meow_img_h(im); + uint16_t *px = (uint16_t *)malloc((size_t)w * h * sizeof *px); + if (px) { + meow_gfx_rgba_to_rgb565(meow_img_pixels(im), px, w * h); + if (ow && oh) { *ow = w; *oh = h; } + free(px); + } + meow_img_free(im); + } + return peak_since(base); +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -461,16 +598,18 @@ int main(void) } int have_jpeg = gen_jpeg_fixtures(jpgbook); - /* 连翻 30 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 - * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 */ + /* 顺序前翻 30 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 + * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 + * M32:夹具 31 页,保证 30 次顺序前翻不触发册间衔接(末页再前翻会开相邻册)。 */ static const char *const g_mix[] = { "base2000.jpg", "prog2000.jpg", "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", }; const int mix_n = (int)(sizeof g_mix / sizeof g_mix[0]); + const int mixed_goal = 31; int mixed_pages = 0; if (have_jpeg) { - for (int i = 0; i < 30; i++) { + for (int i = 0; i < mixed_goal; i++) { char src[MR_PATH_MAX], dst[MR_PATH_MAX]; snprintf(src, sizeof src, "%s/%s", jpgbook, g_mix[i % mix_n]); snprintf(dst, sizeof dst, "%s/p%02d_%s", mixed, i, g_mix[i % mix_n]); @@ -513,12 +652,21 @@ int main(void) size_t measured_kb = peak_since(b0) / 1024 + 1; int got = im ? 1 : 0; if (im) meow_img_free(im); - INFO("png 600x960: model=%lldkB measured=%zukB decoded=%d", model, measured_kb, got); + long long conv = mr_reader_ui_decode_convert_kb(p, 1, 480, 768); + size_t measured_565 = measure_page_565(p, NULL, 1, 1, 480, 768, NULL, NULL) / 1024 + 1; + INFO("png 600x960: model=%lldkB measured=%zukB conv=%lldkB measured565=%zukB decoded=%d", + model, measured_kb, conv, measured_565, got); CHECK(got, "png fixture decodes (writer valid)"); CHECK(model >= (long long)measured_kb, "model is an upper bound (%lldkB < %zukB)", model, measured_kb); CHECK(model <= (long long)measured_kb * 2, "model not wildly pessimistic (%lldkB vs %zukB)", model, measured_kb); + /* M32:565 落槽暂存 = 2×tw×th(480×768 → 720KB),且「解码+落槽」总量仍是上界。 */ + CHECK(conv == 480 * 768 * 2 / 1024, "565 落槽暂存 = 2×w×h (%lldkB)", conv); + CHECK(model + conv >= (long long)measured_565, + "解码+落槽 模型 ≥ 实测 (%lld+%lld < %zukB)", model, conv, measured_565); + CHECK(model + conv <= (long long)measured_565 * 2, + "解码+落槽 模型不离谱保守 (%lld+%lld vs %zukB)", model, conv, measured_565); } /* ---- B5 内存门控:MemAvailable 不足 → 不解码、占位(可重试)---- */ @@ -554,9 +702,27 @@ int main(void) mr_reader_open_dir(book); pump_ms(800); int w = meow_ui_width(); + /* 预热两翻(M32):首次进页窗口只有「焦点 + 下一页」(2 页),稳定态是焦点 ±1(3 页); + * 565 下第 3 页只要 600KB,会正常驻留下来。不预热就会把「多缓存一张预取页」 + * 误判成内存漂移。 */ + tap(40, 300); + pump_ms(200); + tap(w - 40, 300); + pump_ms(200); int d0 = mr_reader_ui_decode_count(); long long resident_cap = mr_reader_ui_resident_cap_kb(); long long cap = mr_reader_ui_peak_cap_kb(); + long total_resident_max = 0; + /* M32 预算自洽(按 565 重算,M31 两档已回滚 → 单档):常驻上限 = 3 槽 + 1 张在途 + * = 4 × 800KB(真机 480×853 页 @565);单页像素上限沿用 RGBA 3MB 的同几何口径 + * (显示清晰度不变,只把每像素 4B 变 2B);单次取页仍受 page_source 的 3MB 常态 + * 预算 / 12MB 硬上限约束(reader_estimate_peak 的 peak_bytes + convert_bytes)。 */ + CHECK(resident_cap == 4 * 800, "常驻上限按 565 重算(%lldKB = 4×800KB)", resident_cap); + CHECK(mr_reader_ui_page_px_cap() == 786432, + "单页像素上限沿用 RGBA 3MB 的同几何口径(%ldpx)", mr_reader_ui_page_px_cap()); + CHECK(cap == 3 * 1024, "单次解码常态预算 3MB(%lldKB)", cap); + CHECK(resident_cap + cap <= 12 * 1024, + "常驻 + 一次取页 ≤ 12MB 包络(%lld + %lld)", resident_cap, cap); /* 基线 = 首屏稳定后的存活字节;之后任何分配都不该再超过 基线+3MB(= 单页解码峰值)。 */ size_t live_steady = g_live; size_t peak_steady = g_peak; @@ -569,17 +735,22 @@ int main(void) if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); meow_ui_paint_full(); long kb = mr_reader_ui_resident_kb(); - if (kb > resident_cap) { + long pend = mr_reader_ui_pending_kb(); + if (kb + pend > resident_cap) { g_fail++; - printf(" FAIL flip %d: resident %ldkB > cap %lldkB\n", i, kb, resident_cap); + printf(" FAIL flip %d: resident %ldkB + pending %ldkB > cap %lldkB\n", + i, kb, pend, resident_cap); break; } + if (kb > total_resident_max) total_resident_max = kb; } size_t transient = g_peak > peak_steady ? g_peak - peak_steady : 0; size_t live_end = g_live; int decodes = mr_reader_ui_decode_count() - d0; - INFO("100 flips: steady_live=%zuKB peak_transient=%zuKB end_live=%zuKB cap=%lldKB decodes=%d advanced=%d", - live_steady / 1024, transient / 1024, live_end / 1024, cap, decodes, advanced); + INFO("100 flips: steady_live=%zuKB peak_transient=%zuKB end_live=%zuKB cap=%lldKB " + "resident_max=%ldKB decodes=%d advanced=%d", + live_steady / 1024, transient / 1024, live_end / 1024, resident_cap, + total_resident_max, decodes, advanced); CHECK(advanced == 100, "every flip advanced the page (%d/100)", advanced); CHECK(transient <= (size_t)cap * 1024 + 512 * 1024, "no allocation above steady+3MB over 100 flips (%zuKB)", transient / 1024); @@ -591,6 +762,10 @@ int main(void) mr_reader_ui_failed_pages()); /* 取消/预取纪律:100 次翻页的解码次数必须有界(M20 实测曾 1.75× 浪费)。 */ CHECK(decodes <= 100 * 2, "decode count bounded over 100 flips (%d)", decodes); + /* M32:槽内页图是 565(2B/px)。480×640 页 = 600KB(RGBA 时代 1200KB)。 */ + CHECK(mr_reader_ui_slot_kb(mr_reader_ui_page()) == 600, + "单页常驻 = w×h×2 = 600KB(实测 %ldKB)", + mr_reader_ui_slot_kb(mr_reader_ui_page())); meow_view_page_pop(); pump_ms(200); } @@ -617,6 +792,87 @@ int main(void) pump_ms(200); } + /* ---- B12 M32:500 页 CBZ 排版只读条目**前缀**(4KB),不再整页解压 ---- + * 改前:zip_ops_meta 走 mz_read_alloc(整条目解压)→ 500 页 = 500 次全量解压; + * 改后:mz_read_prefix(stored 只读前 4KB / deflate 解到 4KB 即停)。 + * 两个夹具:stored 量「每页分配峰值」(旧 27KB/页 → 新 0);deflate 是真实 CBZ 形态, + * 量前缀读省下的 inflate 字节与耗时。 */ + { + char zsrc[MR_PATH_MAX]; + snprintf(zsrc, sizeof zsrc, "%s/zip_src.bmp", root); + int zok = write_bmp24(zsrc, 96, 96, 7) == 0; /* 96×96 24bpp = 27648B 像素数据 */ + CHECK(zok, "CBZ 夹具源图写入"); + for (int mode = 0; zok && mode < 2; mode++) { + int deflate = mode; /* 0=stored, 1=deflate */ + char zcbz[MR_PATH_MAX]; + snprintf(zcbz, sizeof zcbz, "%s/layout500_%s.cbz", root, + deflate ? "deflate" : "stored"); + if (write_zip_cbz(zcbz, zsrc, 500, deflate) != 0) { + CHECK(0, "500 页 CBZ 夹具写入(%s)", deflate ? "deflate" : "stored"); + continue; + } + mr_book_handle zh; + mr_book zb; + int zn = mr_book_open_auto(&zb, &zh, zcbz, 720, NULL, NULL, NULL); + CHECK(zn == 500, "CBZ 页数=%d(%s)", zn, deflate ? "deflate" : "stored"); + if (zn != 500) { mr_book_close_auto(&zb, &zh); continue; } + + /* 旧路径读数:整条目读(= 改前 zip_ops_meta 的做法),量每页分配峰值与总耗时。 */ + size_t old_page_peak = 0; + uint64_t t_old = meow_ui_time_ms(); + const mz_entry *e0 = mz_entry_at((mz_archive *)zh.u.zip.arch, 0); + for (int i = 0; i < 500 && e0; i++) { + void *buf = NULL; + size_t len = 0; + size_t b0 = g_live; + peak_reset(); + if (mz_read_alloc((mz_archive *)zh.u.zip.arch, e0, &buf, &len) == MZ_OK) { + size_t pk = peak_since(b0); + if (pk > old_page_peak) old_page_peak = pk; + } + free(buf); + } + uint64_t ms_old = (uint64_t)meow_ui_time_ms() - t_old; + + /* 新路径:与条漫排版同一条调用(mr_book_meta 逐页)。 + * 第一页会一次性分配 meta 缓存(2×500×4B = 4000B),基线放在它之后, + * 这样量到的就是「每页前缀读」自己的分配。 */ + int pw0 = 0, ph0 = 0; + int first_ok = mr_book_meta(&zb, 0, &pw0, &ph0); + uint64_t t_new = meow_ui_time_ms(); + size_t base = peak_reset(); + int meta_ok = first_ok, dim_ok = (pw0 == 96 && ph0 == 96); + for (int i = 1; i < 500; i++) { + int pw = 0, ph = 0; + if (!mr_book_meta(&zb, i, &pw, &ph)) meta_ok = 0; + else if (pw != 96 || ph != 96) dim_ok = 0; + } + size_t new_peak = peak_since(base); + uint64_t ms_new = (uint64_t)meow_ui_time_ms() - t_new; + + long long bytes_old = 500LL * (long long)(e0 ? e0->uncomp_size : 0); + long long bytes_new = 500LL * 4096; /* 上限:每页最多读 4KB */ + INFO("CBZ 500 页[%s]:旧=整页解压 %lldkB/页·总%lldkB 峰值%zuKB 耗时%llums;" + "新=前缀 %lldkB/页·总%lldkB 峰值%zuB 耗时%llums", + deflate ? "deflate" : "stored", + e0 ? (long long)e0->uncomp_size / 1024 : 0LL, bytes_old / 1024, + old_page_peak / 1024, (unsigned long long)ms_old, + 4LL, bytes_new / 1024, new_peak, (unsigned long long)ms_new); + CHECK(meta_ok, "[%s] 500 页 meta 全部探测成功", deflate ? "deflate" : "stored"); + CHECK(dim_ok, "[%s] 500 页尺寸都取到 96x96", deflate ? "deflate" : "stored"); + CHECK(new_peak <= 4096, + "[%s] 排版期(首帧之后)每页堆峰值 ≤4KB(实测 %zuB)", + deflate ? "deflate" : "stored", new_peak); + CHECK(old_page_peak >= 16 * 1024, + "[%s] 对照:旧路径每页分配 ≥16KB(实测 %zuB)", + deflate ? "deflate" : "stored", old_page_peak); + CHECK(bytes_new < bytes_old / 4, + "[%s] 前缀读总字节 < 旧路径 1/4(%lldkB vs %lldkB)", + deflate ? "deflate" : "stored", bytes_new / 1024, bytes_old / 1024); + mr_book_close_auto(&zb, &zh); + } + } + /* ---- B7 M27:6 类典型页 —— 全部走后端、不占位、模型 ≥ 实测且 ≤1.5× ---- */ if (have_jpeg) { static const char *const typical[] = { @@ -655,6 +911,19 @@ int main(void) typical[i]); CHECK(ow == box_w && oh == box_h, "%s 输出 %dx%d == 显示框 %dx%d", typical[i], ow, oh, box_w, box_h); + /* M32:565 落槽暂存(2B/px)与解码峰值分开建模,但同属一次取页; + * 两者之和必须是「解码 → 转 565」真实峰值的上界(且不保守得离谱)。 */ + size_t measured565 = measure_page_565(p, &in, dec.step, 1, box_w, box_h, + NULL, NULL) + 1; + CHECK(dec.convert_bytes == (long long)dec.tw * dec.th * 2, + "%s 落槽暂存 = 2×tw×th (%lldB, %dx%d)", typical[i], dec.convert_bytes, + dec.tw, dec.th); + CHECK(dec.peak_bytes + dec.convert_bytes >= (long long)measured565, + "%s 解码+落槽 模型 ≥ 实测 (%lld+%lldkB < %zukB)", typical[i], + dec.peak_bytes / 1024, dec.convert_bytes / 1024, measured565 / 1024); + CHECK(dec.peak_bytes + dec.convert_bytes <= (long long)measured565 * 3 / 2, + "%s 解码+落槽 模型 ≤1.5× 实测 (%lldkB vs %zukB)", typical[i], + (dec.peak_bytes + dec.convert_bytes) / 1024, measured565 / 1024); } /* 输出尺寸口径:后端直出 == 「stbi_load_scaled(step) + 落框精修」旧路径 * (典型页必须逐像素同尺寸;源尺寸不是档位整数倍时 M23 已记录 1px 舍入口径差)。 */ @@ -762,37 +1031,51 @@ int main(void) printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } - /* ---- B8 M27:连翻 30 页(6 类典型页循环)——不掉页、不占位、峰值 ≤3MB ---- */ - if (have_jpeg && mixed_pages == 30) { + /* ---- B8 M27/M32:顺序前翻 30 页(31 页册,6 类典型页循环)——不掉页、不占位、 + * 峰值不增长、预取命中率、解码次数有界,且常驻按 565 口径有界 ---- */ + if (have_jpeg && mixed_pages == mixed_goal) { mr_reader_ui_test_set_mem(4096, 16000); mr_reader_open_dir(mixed); pump_ms(1200); - CHECK(mr_reader_ui_count() == 30, "mixed book pages=%d", mr_reader_ui_count()); - int w = meow_ui_width(); + CHECK(mr_reader_ui_count() == mixed_goal, "mixed book pages=%d", mr_reader_ui_count()); int advanced = 0, skipped_max = 0; size_t live_steady = g_live; int d0 = mr_reader_ui_decode_count(); - /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 - * 「分配新图 + 解码」的真实峰值;指标 = 整个 30 翻窗口内相对稳态基线的最大存活增量。 - * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ + int hits0 = mr_reader_ui_flip_hits(), miss0 = mr_reader_ui_flip_misses(); + /* 只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 + * 「分配新图 + 解码」的真实峰值;指标 = 整个窗口内相对稳态基线的最大存活增量。 */ size_t base = peak_reset(); for (int i = 0; i < 30; i++) { int before = mr_reader_ui_page(); - tap(i % 2 == 0 ? 40 : w - 40, 300); - pump_ms(220); - if (mr_reader_ui_page() != before) advanced++; + tap(40, 300); /* RTL:左 1/3 = 下一页(顺序前翻) */ + pump_ms(260); + if (mr_reader_ui_page() == before + 1) advanced++; if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); } size_t transient = g_peak > base ? g_peak - base : 0; - INFO("30 mixed flips: advanced=%d skipped=%d decodes=%d steady=%zuKB " - "resident=%ldKB max_live_delta=%zuKB", - advanced, skipped_max, mr_reader_ui_decode_count() - d0, - live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); - CHECK(advanced == 30, "30 页全部翻到 (%d/30)", advanced); + int hits = mr_reader_ui_flip_hits() - hits0; + int miss = mr_reader_ui_flip_misses() - miss0; + int decodes = mr_reader_ui_decode_count() - d0; + INFO("30 sequential flips: advanced=%d last=%d hits=%d miss=%d skipped=%d decodes=%d " + "steady=%zuKB resident=%ldKB pending=%ldKB max_live_delta=%zuKB", + advanced, mr_reader_ui_page(), hits, miss, skipped_max, decodes, + live_steady / 1024, mr_reader_ui_resident_kb(), mr_reader_ui_pending_kb(), + transient / 1024); + CHECK(advanced == 30 && mr_reader_ui_page() == 30, + "30 次顺序前翻逐页推进(advanced=%d last=%d)", advanced, mr_reader_ui_page()); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); + /* 顺序阅读的预取命中率:目标页已在槽(预取生效)≥90%。 */ + CHECK(hits >= 27, "顺序翻页预取命中率 ≥90%%(hit=%d miss=%d)", hits, miss); + /* 单档(M31 已回滚):每页最多 1 次解码 + 开册/补邻居余量。 */ + CHECK(decodes <= 30 + 15, "30 次翻页解码次数 ≤1/页+余量 (decodes=%d)", decodes); + CHECK(mr_reader_ui_resident_kb() + mr_reader_ui_pending_kb() <= + mr_reader_ui_resident_cap_kb(), + "常驻 + 在途 ≤ 上限 (%ldKB + %ldKB ≤ %ldKB)", + mr_reader_ui_resident_kb(), mr_reader_ui_pending_kb(), + mr_reader_ui_resident_cap_kb()); CHECK(transient <= 3 * 1024 * 1024, "30 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); meow_view_page_pop(); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 5a84afc..74093c6 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -15,7 +15,11 @@ * P5 「先点适高,再切条漫」:scaled 不增长(评审点名的第二入口); * P6 双击放大再双击缩回:缩回后按新几何重解 → scaled 不增长(P2-1); * P7 静止若干帧不产生新解码(无重解抖动/活锁,P2-2); - * P8 驻留页图有字节上界(<= 9MB)。 + * P8 驻留页图有字节上界(<= 9MB); + * P12(M32)颜色正确性:页槽改 RGB565 后, + * a) 565 blit 与 RGBA blit 在 565 屏上**逐像素一致**(同一套量化 → 不引入色偏); + * b) 565 量化误差在理论上界内(R/B ≤7、G ≤3),并给出 RMSE 与代表性采样; + * c) 8bit 灰阶梯/肤色渐变单调、无新增色带(与 RGBA 路径的阶梯数完全相同)。 * * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 */ @@ -25,6 +29,7 @@ #include "logic/reader/nav.h" #include +#include #include #include #include @@ -83,6 +88,50 @@ static int write_bmp24(const char *path, int w, int h, unsigned seed) return ok ? 0 : -1; } +/* 颜色测试夹具:mode 0 = 8bit 灰阶梯(x 方向 0→255);mode 1 = 肤色渐变(R/G/B 同向缓降)。 */ +static int write_bmp24_grad(const char *path, int w, int h, int mode) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < w; x++) { + int r, g, b; + if (mode == 0) { + int v = w > 1 ? x * 255 / (w - 1) : 0; + r = g = b = v; + } else { + r = 235 - (x * 65) / (w > 1 ? w - 1 : 1); + g = 175 - (x * 60) / (w > 1 ? w - 1 : 1); + b = 130 - (x * 45) / (w > 1 ? w - 1 : 1); + } + row[x * 3 + 0] = (unsigned char)b; /* BMP 是 BGR */ + row[x * 3 + 1] = (unsigned char)g; + row[x * 3 + 2] = (unsigned char)r; + } + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + static int mkdir_p(const char *path) { char tmp[MR_PATH_MAX]; @@ -156,6 +205,125 @@ static void mode_toggle(void) static void draw_once(void) { meow_ui_paint_full(); } +/* ---- P12(M32):RGB565 页槽颜色正确性 ---- + * 同一张图用两条路径各画一次,逐像素比较 back_buf(565 屏): + * a) RGBA 路径 meow_ui_img → put_px → rgb565()(今天的上屏方式) + * b) 565 路径 meow_gfx_blit_rgb565(页槽新路径) + * 两条路径本该逐像素相同(同一个量化函数);再量 565 相对 8bit 源的量化误差, + * 确认不出界、灰阶梯单调(不新增色带)。 */ +static void capture565(int x, int y, int w, int h, uint16_t *out) +{ + int pitch = meow_ui_back_buf_pitch(); + unsigned char *bb = meow_ui_back_buf(); + for (int yy = 0; yy < h; yy++) + for (int xx = 0; xx < w; xx++) + out[(size_t)yy * w + xx] = + *(const uint16_t *)(bb + (size_t)(y + yy) * pitch + (size_t)(x + xx) * 2); +} + +static int ex5i(int v) { return (v << 3) | (v >> 2); } +static int ex6i(int v) { return (v << 2) | (v >> 4); } + +static void color_check(void) +{ + const char *dir = getenv("MEOW_DATA_DIR"); + if (!dir || !dir[0]) dir = "."; + if (meow_ui_back_buf_bpp() != 16) { + printf(" SKIP color check: host back_buf is %d bpp (only 565 screens are comparable)\n", + meow_ui_back_buf_bpp()); + return; + } + struct { const char *name; int w, h, mode; const char *label; } fx[2] = { + { "color_gray.bmp", 256, 64, 0, "8bit 灰阶梯 256→0..255" }, + { "color_skin.bmp", 480, 360, 1, "肤色渐变 235,175,130 → 170,115,85" }, + }; + for (size_t k = 0; k < sizeof fx / sizeof fx[0]; k++) { + char path[MR_PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", dir, fx[k].name); + if (write_bmp24_grad(path, fx[k].w, fx[k].h, fx[k].mode) != 0) { + CHECK(0, "颜色夹具写入 %s", fx[k].name); + continue; + } + meow_img *im = meow_img_load_file(path); + if (!im) { CHECK(0, "颜色夹具解码 %s", fx[k].name); continue; } + int w = meow_img_w(im), h = meow_img_h(im); + size_t n = (size_t)w * h; + uint16_t *px = (uint16_t *)malloc(n * 2); + uint16_t *ref = (uint16_t *)malloc(n * 2); + uint16_t *got = (uint16_t *)malloc(n * 2); + if (!px || !ref || !got) { + CHECK(0, "颜色夹具缓冲分配"); + free(px); free(ref); free(got); meow_img_free(im); continue; + } + meow_gfx_rgba_to_rgb565(meow_img_pixels(im), px, (int)n); + + meow_ui_clear(0x000000); + meow_ui_img(0, 0, im); /* 旧路径:RGBA blit */ + capture565(0, 0, w, h, ref); + meow_ui_clear(0x000000); + meow_gfx_blit_rgb565(0, 0, w, h, px); /* 新路径:565 blit(页槽) */ + capture565(0, 0, w, h, got); + + size_t diff = 0; + for (size_t i = 0; i < n; i++) if (ref[i] != got[i]) diff++; + + /* 量化误差:565 展开回 8bit 与源像素比。 */ + const unsigned char *rgba = meow_img_pixels(im); + long long se = 0; + int maxrb = 0, maxg = 0; + for (size_t i = 0; i < n; i++) { + int r5 = (got[i] >> 11) & 0x1F, g6 = (got[i] >> 5) & 0x3F, b5 = got[i] & 0x1F; + int er = ex5i(r5) - rgba[i * 4 + 0]; + int eg = ex6i(g6) - rgba[i * 4 + 1]; + int eb = ex5i(b5) - rgba[i * 4 + 2]; + if (abs(er) > maxrb) maxrb = abs(er); + if (abs(eb) > maxrb) maxrb = abs(eb); + if (abs(eg) > maxg) maxg = abs(eg); + se += (long long)er * er + (long long)eg * eg + (long long)eb * eb; + } + double rmse = sqrt((double)se / (double)(n * 3)); + + /* 灰阶梯:输出必须单调不减,且阶梯数与 RGBA 路径完全一致(没有新增色带)。 */ + int mono = 1, lv = 1, lv_ref = 1; + for (int x = 1; x < w; x++) { + uint16_t a = got[(size_t)(h / 2) * w + x], b = got[(size_t)(h / 2) * w + x - 1]; + uint16_t ar = ref[(size_t)(h / 2) * w + x], br = ref[(size_t)(h / 2) * w + x - 1]; + if (a < b) mono = 0; + if (a != b) lv++; + if (ar != br) lv_ref++; + } + + printf(" [P12] %-42s 差异像素=%zu/%zu 误差 max R/B=%d G=%d RMSE=%.2f 阶梯=%d\n", + fx[k].label, diff, n, maxrb, maxg, rmse, lv); + if (fx[k].mode == 0) { + for (int x = 0; x < w; x += w / 8) { + int i = (h / 2) * w + x; + printf(" x=%3d 源灰度=%3d → RGBA路径=0x%04X 565路径=0x%04X\n", + x, rgba[(size_t)i * 4], ref[i], got[i]); + } + } else { + for (int x = 0; x < w; x += w / 4) { + int i = (h / 2) * w + x; + printf(" x=%3d 源 RGB=(%3d,%3d,%3d) → RGBA路径=0x%04X 565路径=0x%04X\n", + x, rgba[(size_t)i * 4], rgba[(size_t)i * 4 + 1], rgba[(size_t)i * 4 + 2], + ref[i], got[i]); + } + } + + CHECK(diff == 0, "%s:565 blit 与 RGBA blit 逐像素一致(差异 %zu)", fx[k].label, diff); + CHECK(maxrb <= 7 && maxg <= 3, + "%s:量化误差在 565 理论界内(R/B %d ≤7,G %d ≤3)", fx[k].label, maxrb, maxg); + CHECK(rmse <= 3.5, "%s:RMSE %.2f ≤3.5", fx[k].label, rmse); + if (fx[k].mode == 0) { + CHECK(mono, "%s:灰阶梯输出单调(无色带跳变/反转)", fx[k].label); + CHECK(lv >= 30 && lv == lv_ref, + "%s:阶梯数 %d ≥30 且与 RGBA 路径一致(%d)", fx[k].label, lv, lv_ref); + } + free(px); free(ref); free(got); + meow_img_free(im); + } +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -328,6 +496,9 @@ int main(void) mr_reader_ui_decode_count()); CHECK(kb <= 9 * 1024, "resident page images bounded (got %ldKB)", kb); + /* ---- P12(M32):565 页槽的颜色正确性(需要 565 屏的 back_buf)---- */ + color_check(); + /* ---- P9 800x1280(评审表格另一档):翻页 + 条漫都必须零重采样 ---- */ meow_view_page_pop(); mr_reader_open_dir(book2); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index ded7039..4b073b3 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -15,8 +15,12 @@ * 宽恒为视口宽),不是屏长边,所以翻页/滚动热路径只有原生 blit:不做 meow_ui_img_scaled * 的每帧重采样,也不再每帧 malloc 1MB 级临时缓冲。几何变化(fit/缩放/切模式)时按新几何 * 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 - * 内存(M17 起;M22 按真机实测重定;M27 接真缩解后端):单页显示缓存 ≤3MB、整窗常驻 - * ≤3MB(3 槽,超限淘汰最远页)。解码路径与峰值预算由 page_source 的 reader_estimate_peak() + * 内存(M17 起;M22 按真机实测重定;M27 接真缩解后端;M32 页槽改 RGB565): + * 槽里常驻的是**已落框的 565 位图**(2B/px),单页上限 1.5MB(旧 RGBA 3MB 的同几何口径)、 + * 整窗常驻上限 3.2MB(3 槽 + 1 张在途,超限淘汰最远页)。真机 480×853 页 1599KB → 800KB, + * 两页常驻 3.2MB → 1.6MB。解码仍产出 RGBA,worker 当场转 565 再释放 RGBA;这笔暂存 + * (2B/px)由 reader_estimate_peak 的 convert_bytes 计入预算。解码路径与峰值预算由 + * page_source 的 reader_estimate_peak() * 统一判定(唯一实现,本文件不再另写模型): * - 真缩解后端(M23)能接住的页:峰值只与目标尺寸相关(≈1.3–2.5MB,与源分辨率无关), * 直接放行,不做内存门控; @@ -77,16 +81,25 @@ #define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ #define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ #define MR_TOAST_MS 1400 -/* ---- 内存预算(M22 校准;M27 起模型与门控只在 page_source.c 的 reader_estimate_peak 里)---- - * 真机参考:MemTotal 56872kB、空闲 RSS 6044kB、杀点 RSS≈15.6MB / MemFree 仅 1.4MB。 */ +/* ---- 内存预算(M22 校准;M27 起模型与门控只在 page_source.c 的 reader_estimate_peak 里; + * M32 页槽改 RGB565,2 字节/像素,所有 RGBA 时代的字节常量在这里按 565 重算)---- + * 真机参考:MemTotal 56872kB、空闲 RSS 6044kB、杀点 RSS≈15.6MB / MemFree 仅 1.4MB。 + * 页图口径:真机页 480×853 = 409,440px;RGBA 1599KB/页 → RGB565 800KB/页。 */ #define MR_PREFETCH_MIN_KB (12L * 1024) /* 预取邻居要求的最低 MemAvailable */ #define MR_SKIP_RETRY_MS 1500 /* 被预算跳过的页:焦点页冷却这么久后自动重试 */ -#define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */ -#define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */ +/* 单页缓存上限:旧口径是 3MB@RGBA = 786432px(480×1638);565 下同几何 = 1.5MB。 + * 保持像素上限不变 → 解码几何/显示清晰度与以前完全一致,只是每像素省 2 字节。 */ +#define MR_PAGE_PX_CAP (786432L) +#define MR_PAGE_BYTES_CAP (MR_PAGE_PX_CAP * 2) /* 1.5MB:单页 565 缓存上限 */ +#define MR_SLOT_BYTES (800L * 1024) /* 真机典型页(480×853)的 565 字节 */ +/* 常驻总上限 = 3 槽 + 1 张「已解码未落槽」的在途结果 = 4 × 800KB = 3.2MB。 + * 旧口径 3MB 只算槽内 RGBA(3 槽最坏 4.8MB,且在途结果完全没算);565 后把在途结果 + * 一起算进来,真实上限反而收紧。典型稳态(2 页)1.6MB,只有上限的一半。 */ +#define MR_RESIDENT_CAP (4 * MR_SLOT_BYTES) #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ #define MR_REQ_MAX 8 -#define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */ +#define MR_RES_MAX 2 /* 结果队列上限(M22:4→2);在途结果的字节由 pending_bytes 计入预算 */ /* view on_event 的事件号不在公共头(sdk/meow_ui_internal.h §6.4);本页只用到 SWIPE=3。 */ #define MR_EV_SWIPE 3 @@ -146,12 +159,19 @@ static int g_win_idx[MR_SLOTS]; static int g_win_n; static int g_fail_paint; /* 窗口内出现解码失败页 → tick 重绘错误占位 */ -/* ---- 页图缓存(≤4 张 downscale 后的图)---- */ +/* ---- 页图缓存(≤3 槽,RGB565 常驻)---- + * 槽里存的是**已落框**的 565 位图(2B/px),不是解码器的 RGBA:真机 480×853 页 + * 1599KB → 800KB,两页常驻 3.2MB → 1.6MB。命中页绘制 = 每行一次 memcpy(M32)。 */ +typedef struct page_bmp { + int w, h; + uint16_t *px; /* w*h,行优先,RGB565(不透明) */ +} page_bmp; + typedef struct page_slot { - int idx; /* -1=空 */ - meow_img *img; /* 已 downscale 的页图 */ - int src_w, src_h; - int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */ + int idx; /* -1=空 */ + page_bmp *bmp; /* 已 downscale/落框的 565 页图 */ + int src_w, src_h; /* = bmp->w/h(绘制与观测的几何口径) */ + int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */ } page_slot; static page_slot g_slot[MR_SLOTS]; @@ -185,6 +205,7 @@ static volatile int g_decode_count; /* 累计解码次数(观察重解 #define MR_SKIP_NONE 0 /* 解出来了 */ #define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */ #define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */ +#define MR_SKIP_CONVERT 3 /* 565 落槽缓冲分配失败(可重试,不是坏图) */ static int page_failed(int idx) { @@ -242,7 +263,8 @@ typedef struct page_req { typedef struct page_res { int idx; unsigned gen; - meow_img *img; + page_bmp *bmp; /* 已转 565 的页图(NULL = 失败/跳过) */ + long bytes; /* bmp 的字节数:在途结果的预算口径(M32) */ int fail; int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; @@ -267,6 +289,10 @@ static int g_slide_delta; /* 翻页目标增量(动画中) */ static int g_slide_from; /* 进场页起点(px) */ static int g_anim_v; /* 进场页当前位移(px) */ static uint64_t g_flip_t0; +/* 翻页命中/未命中(观测口,M32 用来验证「命中率不退化」;非产品逻辑): + * 目标页已在槽 = 预取生效,翻页不用等解码。与 M31 的 tier 机制无关。 */ +static volatile int g_flip_hits; +static volatile int g_flip_misses; static uint64_t g_last_tick; static uint64_t g_last_paint; static int g_quitting; /* on_leave 期间抑制回调 */ @@ -352,11 +378,51 @@ static long mem_avail_kb(void) return mem_kb("/proc/meminfo", "MemAvailable:"); } +/* ---- 565 页图:转换 / 释放 / 字节口径(M32) ---- + * 解码后立刻转成 565 并释放 RGBA:转换期间 RGBA(4B/px)+ 565(2B/px)同时存在, + * 这笔暂存由 reader_estimate_peak 的 convert_bytes 计入预算(见 page_source.h)。 + * 失败时也会释放 RGBA(调用方不必再 free),返回 NULL 按「可重试的预算跳过」处理。 */ +static page_bmp *page_bmp_from_img(meow_img *im) +{ + if (!im) return NULL; + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *rgba = meow_img_pixels(im); + page_bmp *b = NULL; + if (rgba && w > 0 && h > 0) { + b = (page_bmp *)malloc(sizeof *b); + uint16_t *px = b ? (uint16_t *)malloc((size_t)w * (size_t)h * sizeof *px) : NULL; + if (b && px) { + meow_gfx_rgba_to_rgb565(rgba, px, w * h); + b->w = w; + b->h = h; + b->px = px; + } else { + free(px); + free(b); + b = NULL; + } + } + meow_img_free(im); + return b; +} + +static void page_bmp_free(page_bmp *b) +{ + if (!b) return; + free(b->px); + free(b); +} + +static long page_bmp_bytes(const page_bmp *b) +{ + return b ? (long)b->w * b->h * 2 : 0; +} + static int resident_pages(void) { int n = 0; for (int i = 0; i < MR_SLOTS; i++) - if (g_slot[i].img) n++; + if (g_slot[i].bmp) n++; return n; } @@ -364,18 +430,30 @@ static long resident_bytes(void) { long b = 0; for (int i = 0; i < MR_SLOTS; i++) - if (g_slot[i].img) b += (long)g_slot[i].src_w * g_slot[i].src_h * 4; + b += page_bmp_bytes(g_slot[i].bmp); + return b; +} + +/* 结果队列里「已解码、还没落槽」的 565 字节(M32:这些图也是真实内存)。 + * 只读主线程路径用它做预算;worker 在 g_lock 内写队列,故取快照也在锁内。 */ +static long pending_bytes(void) +{ + long b = 0; + pthread_mutex_lock(&g_lock); + for (int i = 0; i < g_res_n; i++) b += g_res[i].bytes; + pthread_mutex_unlock(&g_lock); return b; } /* 进出阅读器各打一行(任务 5):RSS 看当前占用,VmHWM 看本次进入的峰值—— - * 验收就是「离开时的 hwm 不超过预算」。 */ + * 验收就是「离开时的 hwm 不超过预算」。img 列 = 槽内 565 字节;pending 未落槽的在途图。 */ static void log_mem(const char *tag) { - meow_log(MEOW_LOG_INFO, "reader mem[%s]: rss=%ldkB hwm=%ldkB memfree=%ldkB memavail=%ldkB pages=%d img=%ldkB", + meow_log(MEOW_LOG_INFO, + "reader mem[%s]: rss=%ldkB hwm=%ldkB memfree=%ldkB memavail=%ldkB pages=%d img=%ldkB pending=%ldkB", tag, mem_kb("/proc/self/status", "VmRSS:"), mem_kb("/proc/self/status", "VmHWM:"), mem_free_kb(), mem_avail_kb(), - resident_pages(), resident_bytes() / 1024); + resident_pages(), resident_bytes() / 1024, pending_bytes() / 1024); } /* ============================================================ */ @@ -481,7 +559,7 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) } if (dec.skip != MR_DECODE_OK) { g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; - log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, + log_decode(page, in.w, in.h, dec.peak_bytes + dec.convert_bytes, mfree, mavail, dec.skip == MR_DECODE_SKIP_LOWMEM ? "skip=lowmem" : "skip=peak", dec.step, dec.path == MR_DECODE_PATH_BACKEND, (unsigned long long)(meow_ui_time_ms() - t0)); @@ -668,15 +746,15 @@ static page_slot *slot_find(int idx) { if (idx < 0) return NULL; for (int i = 0; i < MR_SLOTS; i++) - if (g_slot[i].idx == idx && g_slot[i].img) return &g_slot[i]; + if (g_slot[i].idx == idx && g_slot[i].bmp) return &g_slot[i]; return NULL; } static void slot_clear(int i) { if (i < 0 || i >= MR_SLOTS) return; - if (g_slot[i].img) meow_img_free(g_slot[i].img); - g_slot[i].img = NULL; + page_bmp_free(g_slot[i].bmp); + g_slot[i].bmp = NULL; g_slot[i].idx = -1; g_slot[i].src_w = g_slot[i].src_h = 0; g_slot[i].want_w = g_slot[i].want_h = 0; @@ -711,7 +789,7 @@ static int slot_evict_far(int except) int focus = focus_page(); int far = -1, far_d = -1; for (int i = 0; i < MR_SLOTS; i++) { - if (i == except || !g_slot[i].img) continue; + if (i == except || !g_slot[i].bmp) continue; if (g_slot[i].idx == focus) continue; if (page_visible(g_slot[i].idx)) continue; int d = abs(g_slot[i].idx - focus); @@ -723,24 +801,29 @@ static int slot_evict_far(int except) /* 开新页前先腾地方(任务 3):按「即将落地的单页字节」预判,先淘汰非可见/非焦点的旧图, * 让解码缓冲不与陈旧常驻叠加。只在逼近上限时才动手,避免把预取邻居立刻扔掉 - *(那样翻页会从「命中」退化成「重新解码」,反而增加峰值次数)。 */ + *(那样翻页会从「命中」退化成「重新解码」,反而增加峰值次数)。 + * M32:已解码未落槽的在途结果(pending_bytes)也是真实内存,一起算进常驻上限。 */ static void slots_reserve(long need_bytes) { if (need_bytes <= 0) return; - while (resident_bytes() + need_bytes > MR_RESIDENT_CAP) { + long pending = pending_bytes(); + while (resident_bytes() + pending + need_bytes > MR_RESIDENT_CAP) { + long before = resident_bytes(); if (slot_evict_far(-1) < 0) return; /* 只剩可见页:放行,下一次 slot_put 再收敛 */ + if (resident_bytes() >= before) return; /* 没腾出东西(无可淘汰页):别死循环 */ } } /* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。 - * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */ -static int slot_put(int idx, meow_img *img, int want_w, int want_h) + * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 + * bmp 的所有权移交本槽(失败时在这里释放)。 */ +static int slot_put(int idx, page_bmp *bmp, int want_w, int want_h) { - if (!img || idx < 0) return 0; + if (!bmp || idx < 0) { page_bmp_free(bmp); return 0; } int i = -1, empty = -1; for (int k = 0; k < MR_SLOTS; k++) { - if (g_slot[k].idx == idx && g_slot[k].img) { i = k; break; } - if (!g_slot[k].img && empty < 0) empty = k; + if (g_slot[k].idx == idx && g_slot[k].bmp) { i = k; break; } + if (!g_slot[k].bmp && empty < 0) empty = k; } if (i < 0) { if (empty >= 0) { @@ -752,25 +835,28 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h) int d = abs(g_slot[k].idx - focus); if (d > far_d) { far_d = d; i = k; } } - if (i < 0) i = g_slot[0].img ? 0 : -1; /* 全可见:只能挤掉第一槽(极端条漫页) */ + if (i < 0) i = g_slot[0].bmp ? 0 : -1; /* 全可见:只能挤掉第一槽(极端条漫页) */ } } - if (i < 0) { meow_img_free(img); return 0; } + if (i < 0) { page_bmp_free(bmp); return 0; } - /* 总字节上限:新图放进去前先淘汰最远页,保证常驻图不超过预算。 */ - long need = (long)meow_img_w(img) * meow_img_h(img) * 4; + /* 总字节上限:新图放进去前先淘汰最远页,保证常驻 + 在途不超过预算。 */ + long need = page_bmp_bytes(bmp); + long pending = pending_bytes(); for (;;) { - long held = resident_bytes(); - if (g_slot[i].img) held -= (long)g_slot[i].src_w * g_slot[i].src_h * 4; + long held = resident_bytes() + pending; + if (g_slot[i].bmp) held -= page_bmp_bytes(g_slot[i].bmp); if (held + need <= MR_RESIDENT_CAP) break; + long before = resident_bytes(); if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */ + if (resident_bytes() >= before) break; /* 没腾出东西:收手,别死循环 */ } slot_clear(i); g_slot[i].idx = idx; - g_slot[i].img = img; - g_slot[i].src_w = meow_img_w(img); - g_slot[i].src_h = meow_img_h(img); + g_slot[i].bmp = bmp; + g_slot[i].src_w = bmp->w; + g_slot[i].src_h = bmp->h; g_slot[i].want_w = want_w; g_slot[i].want_h = want_h; return 1; @@ -856,8 +942,9 @@ static void queue_page(int idx) if (want_w <= 0 || want_h <= 0) { want_w = g_vw; want_h = g_vh; } int box_w = 0, box_h = 0; plan_box_size(want_w, want_h, &box_w, &box_h); - /* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */ - slots_reserve((long)box_w * box_h * 4); + /* 先释放再解码:按这一页的落地字节(565)先淘汰不可见旧图,别让常驻与解码缓冲叠加。 + * slots_reserve 内部按「槽内 + 在途结果」对新页做预算(M32:在途结果也算内存)。 */ + slots_reserve((long)box_w * box_h * 2); pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { page_req *r = &g_req[g_req_tail]; @@ -906,22 +993,31 @@ static void *reader_worker(void *arg) g_decode_skip = MR_SKIP_NONE; meow_img *img = mr_book_open(&g_book, req.idx); g_decode_count++; - int skip = img ? MR_SKIP_NONE : g_decode_skip; + /* 565 落槽(M32):解码结果只用来生成槽里的 565 位图,RGBA 当场释放。 + * 放在 worker 里做——主线程只收一张已经能直接 blit 的图,翻页不等转换。 */ + int had_img = img != NULL; + page_bmp *bmp = had_img ? page_bmp_from_img(img) : NULL; /* 接管并释放 RGBA */ + img = NULL; + if (had_img && !bmp) { + meow_log(MEOW_LOG_WARN, "reader decode page=%d 565-convert failed (oom)", req.idx); + } + int skip = bmp ? MR_SKIP_NONE : (had_img ? MR_SKIP_CONVERT : g_decode_skip); pthread_mutex_lock(&g_lock); if (!g_stop && g_res_n < MR_RES_MAX) { g_res[g_res_n].idx = req.idx; g_res[g_res_n].gen = req.gen; - g_res[g_res_n].img = img; - g_res[g_res_n].fail = (img || skip) ? 0 : 1; + g_res[g_res_n].bmp = bmp; + g_res[g_res_n].bytes = page_bmp_bytes(bmp); + g_res[g_res_n].fail = (bmp || skip) ? 0 : 1; g_res[g_res_n].skip = skip; g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; g_res_n++; - img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ + bmp = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } pthread_mutex_unlock(&g_lock); - if (img) meow_img_free(img); + page_bmp_free(bmp); } return NULL; } @@ -944,7 +1040,7 @@ static void worker_stop(void) g_worker_on = 0; pthread_mutex_lock(&g_lock); - for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + for (int i = 0; i < g_res_n; i++) page_bmp_free(g_res[i].bmp); g_res_n = 0; g_req_n = 0; g_req_head = g_req_tail = 0; @@ -987,9 +1083,9 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { - if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } + if (got[i].gen != g_gen) { page_bmp_free(got[i].bmp); continue; } if (got[i].skip) { - /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ + /* 预算跳过(内存不足/峰值超限/565 转换失败):可重试,不写永久失败位。 */ page_mark_skipped(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; @@ -1000,11 +1096,11 @@ static int results_drain(void) continue; } if (in_window(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h) && + if (slot_put(got[i].idx, got[i].bmp, got[i].want_w, got[i].want_h) && page_visible(got[i].idx)) changed = 1; page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ } else { - meow_img_free(got[i].img); + page_bmp_free(got[i].bmp); } } return changed; @@ -1176,8 +1272,8 @@ static int pan_mode(void) return 0; } -/* 画一页(带水平位移);解码尺寸==显示尺寸时走原生 blit(翻页热路径零重采样), - * 只在 fit/zoom 变化或极端长页(超单页缓存上限)时才退回最近邻缩放。 */ +/* 画一页(带水平位移);页图尺寸==显示尺寸时走原生 565 blit(翻页热路径零重采样、 + * 每行一次 memcpy),只在 fit/zoom 变化或极端长页(超单页缓存上限)时才退回最近邻缩放。 */ static void draw_page_at(int idx, int off_x, int off_y) { page_slot *s = slot_find(idx); @@ -1188,16 +1284,17 @@ static void draw_page_at(int idx, int off_x, int off_y) int x = (g_vw - w) / 2 + g_pan_x + off_x; int y = (g_vh - h) / 2 + g_pan_y + off_y; if (abs(w - s->src_w) <= MR_SLOT_TOL && abs(h - s->src_h) <= MR_SLOT_TOL) { - meow_ui_img(x + (w - s->src_w) / 2, y + (h - s->src_h) / 2, s->img); + meow_gfx_blit_rgb565(x + (w - s->src_w) / 2, y + (h - s->src_h) / 2, + s->src_w, s->src_h, s->bmp->px); NOTE_BLIT(); } else { - meow_ui_img_scaled(x, y, w, h, s->img); + meow_gfx_blit_rgb565_scaled(x, y, w, h, s->src_w, s->src_h, s->bmp->px); NOTE_SCALED(); } } -/* 条漫:按滚动偏移画可见页。典型 2:3 页的解码尺寸与显示尺寸相等 → 原生 blit; - * 其余按最近邻缩放(缩放在 SDK 内部申请目标尺寸临时缓冲,单页峰值受控)。 */ +/* 条漫:按滚动偏移画可见页。典型 2:3 页的页图尺寸与显示尺寸相等 → 原生 565 blit; + * 其余按最近邻缩放(gfx 内直接采样,不再申请目标尺寸临时缓冲)。 */ static void draw_strip(const meow_theme *th) { if (!g_strip_ok) { @@ -1212,13 +1309,13 @@ static void draw_strip(const meow_theme *th) if (h <= 0) continue; page_slot *s = slot_find(i); if (s) { - /* 解码=显示尺寸时原生 blit:gfx 只处理屏幕上可见的行, + /* 页图=显示尺寸时原生 blit:gfx 只处理屏幕上可见的行, * 长条页只露一截时不再整页重采样(M7 finding 的子矩形 blit 诉求)。 */ if (abs(s->src_w - g_vw) <= MR_SLOT_TOL && abs(s->src_h - h) <= MR_SLOT_TOL) { - meow_ui_img((g_vw - s->src_w) / 2, y, s->img); + meow_gfx_blit_rgb565((g_vw - s->src_w) / 2, y, s->src_w, s->src_h, s->bmp->px); NOTE_BLIT(); } else { - meow_ui_img_scaled(0, y, g_vw, h, s->img); + meow_gfx_blit_rgb565_scaled(0, y, g_vw, h, s->src_w, s->src_h, s->bmp->px); NOTE_SCALED(); } continue; @@ -1507,11 +1604,13 @@ static void page_flip(int delta) g_flip_t0 = meow_ui_time_ms(); if (slot_find(target)) { + g_flip_hits++; if (start_slide(delta, 0)) meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1); else meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1); } else { + g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); } @@ -1762,7 +1861,7 @@ static int open_book(const char *dir, int start_page, int mode) g_plan.box_h = bh; g_plan_page = g_page; meow_img *first = mr_book_open(&g_book, g_page); - if (first) slot_put(g_page, first, ww, wh); + if (first) slot_put(g_page, page_bmp_from_img(first), ww, wh); meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); } slider_build(); @@ -1845,8 +1944,10 @@ static void drag_finish(void) } g_flip_t0 = meow_ui_time_ms(); if (slot_find(target) && start_slide(delta, dx)) { + g_flip_hits++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); } else { + g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); @@ -2519,6 +2620,14 @@ long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); return dec.peak_bytes / 1024; } +long long mr_reader_ui_decode_convert_kb(const char *path, int step, int box_w, int box_h) +{ + mr_img_info in; + mr_decode_decision dec; + if (!path || !mr_probe_info(path, &in)) return 0; + reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec); + return dec.convert_bytes / 1024; +} long mr_reader_ui_peak_cap_kb(void) { return (long)(reader_peak_budget_bytes(-1) / 1024); } long mr_reader_ui_peak_budget_kb(void) { return (long)(reader_peak_budget_bytes(mem_free_kb()) / 1024); } long mr_reader_ui_resident_cap_kb(void) { return (long)(MR_RESIDENT_CAP / 1024); } @@ -2527,6 +2636,20 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } +/* 指定槽内页图的常驻字节(KB,RGB565 2B/px);0=不在槽内(M32 观测口)。 */ +long mr_reader_ui_slot_kb(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return 0; + return page_bmp_bytes(s->bmp) / 1024; +} +/* M32:已解码、还没落槽的在途结果字节(预算口径,供测试断言它也被计入总预算)。 */ +long mr_reader_ui_pending_kb(void) { return pending_bytes() / 1024; } +long mr_reader_ui_page_px_cap(void) { return (long)MR_PAGE_PX_CAP; } +/* 翻页命中/未命中(观测口,非产品逻辑):目标页已在槽 = 预取生效,翻页不用等解码。 + * M31 回滚时整段观测口被删;这两项与 tier 无关,M32 用它验证「命中率不退化」。 */ +int mr_reader_ui_flip_hits(void) { return g_flip_hits; } +int mr_reader_ui_flip_misses(void) { return g_flip_misses; } /* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ void mr_reader_test_toggle_mode(void) { diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 3ddca22..5ffe617 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -37,7 +37,7 @@ int mr_reader_ui_page(void); /* 当前页(条漫=视口顶端所在页 int mr_reader_ui_count(void); /* 当前册页数 */ int mr_reader_ui_jump_open(void); /* 1=跳页滑块已展开 */ int mr_reader_ui_jump_pending(void);/* 1=跳页目标等待解码 */ -long mr_reader_ui_resident_kb(void);/* 驻留页图字节(KB) */ +long mr_reader_ui_resident_kb(void);/* 槽内常驻页图字节(KB,RGB565:2 字节/像素) */ int mr_reader_ui_failed_pages(void);/* 解码失败(坏图)页数 */ int mr_reader_ui_skipped_pages(void);/* 被内存预算跳过(可重试)的页数 */ /* 注入 /proc/meminfo 读数(kB);-1 = 恢复真实读取。用于确定性验证内存门控与放宽档。 */ @@ -45,9 +45,19 @@ void mr_reader_ui_test_set_mem(long free_kb, long avail_kb); /* 峰值模型自检:用产品代码里的同一个模型算预估峰值(kB),供宿主测试与 --wrap malloc * 实测对照。0=探测失败。 */ long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int box_h); +/* M32:同一次取页里的 565 落槽暂存(kB,= 2×tw×th);与 decode_peak 分开报,便于 + * 「解码峰值」与「落槽暂存」各自对照实测。 */ +long long mr_reader_ui_decode_convert_kb(const char *path, int step, int box_w, int box_h); long mr_reader_ui_peak_cap_kb(void); /* 常态峰值上限(kB) */ long mr_reader_ui_peak_budget_kb(void); /* 当前 MemFree 下的实际预算(kB) */ -long mr_reader_ui_resident_cap_kb(void); /* 常驻上限(kB) */ +long mr_reader_ui_resident_cap_kb(void); /* 常驻上限(kB;含在途结果) */ +/* ---- M32 RGB565 页槽预算口径 ---- */ +long mr_reader_ui_pending_kb(void); /* 已解码、还没落槽的在途结果字节(KB) */ +long mr_reader_ui_page_px_cap(void); /* 单页缓存像素上限(旧 RGBA 3MB 的同几何口径) */ +long mr_reader_ui_slot_kb(int idx); /* 指定槽内页图的常驻字节(KB,565);0=不在槽内 */ +/* 翻页命中/未命中(观测口,用于验证「命中率不退化」):目标页已在槽 = 预取生效。 */ +int mr_reader_ui_flip_hits(void); +int mr_reader_ui_flip_misses(void); int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ From f647a3c4986dcc1b9036d856f77e03b1c00383bf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 19:01:16 +0800 Subject: [PATCH 36/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=20M32=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P1=EF=BC=88plan=5Fdecode=5Flong=20?= =?UTF-8?q?=E4=BB=8D=E6=8C=89=204B/px=EF=BC=89+=20=E8=A1=A5=20480=C3=97853?= =?UTF-8?q?=20=E5=9B=9E=E5=BD=92=EF=BC=88r2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1:plan_decode_long 的判据从 l*s*4 改成 l*s*2(MR_PAGE_BYTES_CAP 已是 565 口径)。 改前真机 480×853 页被压到 359×639(448KB)且每帧重采样;改后原尺寸落槽 799KB、 绘制走原生 blit(scaled 增量 0)。 - 测试:新增 perf P13 —— 480×853 单页册,断言 slot_kb==480*853*2/1024、resident 相等、 draw 后 blit 增长且 scaled 增量为 0;现有夹具(480×640/600×960)都在阈值以下,抓不到。 - P2:decode 日志 ok/fail 也打 peak+convert(与 skip 同口径);CBZ 500 页的「前缀字节」 改成 mz_read_prefix 实测(不再用 500×4096 硬算),并说明 zlib 窗口不计入 --wrap 峰值; 565 页图 alpha 忽略的行为差异写进 meow_ui_draw.h / gfx 注释;MR_ZOOM_TEMP_CAP 注释更正。 --- sdk/include/meow_ui_draw.h | 9 +++++-- sdk/meow_ui_gfx.c | 3 ++- tuku/test/test_reader_membudget.c | 22 +++++++++++++---- tuku/test/test_reader_perf.c | 41 +++++++++++++++++++++++++++++++ tuku/ui/reader/reader.c | 25 +++++++++++++------ 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/sdk/include/meow_ui_draw.h b/sdk/include/meow_ui_draw.h index 2c7e5cf..21a2389 100644 --- a/sdk/include/meow_ui_draw.h +++ b/sdk/include/meow_ui_draw.h @@ -54,9 +54,14 @@ void meow_ui_fill_rect_alpha(int x, int y, int w, int h, uint32_t color, int alp * 页槽常驻 RGB565(2 字节/像素):真机 480×853 页 1599KB → 800KB。 * 量化与 back_buf 用同一个 565 转换(逐位截断),565 屏上最终像素与 RGBA blit * 路径逐像素一致——不引入色偏、不会比现在多出色带。 - * 命中页(页图尺寸 == 显示尺寸)走 meow_gfx_blit_rgb565:每行一次拷贝,无重采样。 */ + * 命中页(页图尺寸 == 显示尺寸)走 meow_gfx_blit_rgb565:每行一次拷贝,无重采样。 + * + * alpha:565 页图**按不透明**处理(alpha 被忽略)。漫画页解码后 alpha 恒为 255, + * 与旧路径等价;带透明像素的 PNG(调色板/tRNS/color type 6、或 32bpp BMP)会有可见 + * 差异:旧 RGBA 路径把透明像素混合到阅读器底色,565 路径直接画它的 RGB(通常是黑)。 + * 若将来要支持透明页,需要在上屏时按 alpha 混合(而不是在落槽时丢通道)。 */ uint16_t meow_gfx_rgb565(uint32_t rgb); /* 24-bit RGB → 565(与 back_buf 同式) */ -void meow_gfx_rgba_to_rgb565(const uint8_t *rgba, uint16_t *out, int npx); /* 不透明像素 */ +void meow_gfx_rgba_to_rgb565(const uint8_t *rgba, uint16_t *out, int npx); /* 不透明像素(忽略 alpha) */ /* 原生尺寸 blit(不透明):16bpp back_buf 上是每行一次 memcpy。 */ void meow_gfx_blit_rgb565(int x, int y, int w, int h, const uint16_t *px); /* 最近邻缩放 blit(几何不符时的兜底;采样点与 meow_img_resample 同式)。 */ diff --git a/sdk/meow_ui_gfx.c b/sdk/meow_ui_gfx.c index 76c1d2b..59caa86 100644 --- a/sdk/meow_ui_gfx.c +++ b/sdk/meow_ui_gfx.c @@ -861,7 +861,8 @@ void meow_gfx_blit_rgba(int x, int y, int w, int h, const uint8_t *rgba, int alp * 量化与 back_buf 用同一个 rgb565()(逐位截断),所以 565 屏上落下的像素与旧的 * RGBA blit 路径**逐像素一致**——不引入色偏,也不会比现在多出色带。 * 命中页(解码/落框尺寸 == 显示尺寸)走 meow_gfx_blit_rgb565:每行一次 memcpy, - * 单次拷贝、没有每帧重采样。alpha 按不透明处理(页图没有透明通道)。 */ + * 单次拷贝、没有每帧重采样。alpha 按不透明处理(页图 alpha 恒为 255;带透明像素的 + * PNG/32bpp BMP 页会与旧 RGBA 路径不同——旧路径混合到底色,这里直接画 RGB)。 */ uint16_t meow_gfx_rgb565(uint32_t rgb) { return rgb565(rgb); diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index 48cafa2..b285150 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -850,14 +850,23 @@ int main(void) size_t new_peak = peak_since(base); uint64_t ms_new = (uint64_t)meow_ui_time_ms() - t_new; + /* 前缀字节数**实测**(zip 层 mz_read_prefix 的写出长度),不是按 4KB 硬算: + * 否则「前缀总字节 < 旧 1/4」只要页 >16KB 就恒真,抓不到前缀读变多。 + * 注意 deflate 条目的 inflate 窗口在 libz 内部(动态库,--wrap 看不到), + * 所以下面 new_peak 是「本进程侧」的堆峰值,不是 zlib 窗口。 */ + unsigned char probe[4096]; + size_t probe_len = 0; + int probe_rc = e0 ? mz_read_prefix((mz_archive *)zh.u.zip.arch, e0, + probe, sizeof probe, &probe_len) : MZ_ERR_ARG; long long bytes_old = 500LL * (long long)(e0 ? e0->uncomp_size : 0); - long long bytes_new = 500LL * 4096; /* 上限:每页最多读 4KB */ + long long bytes_new = 500LL * (long long)probe_len; INFO("CBZ 500 页[%s]:旧=整页解压 %lldkB/页·总%lldkB 峰值%zuKB 耗时%llums;" - "新=前缀 %lldkB/页·总%lldkB 峰值%zuB 耗时%llums", + "新=前缀 %lldB/页·总%lldkB 峰值%zuB 耗时%llums(zlib 窗口在库内、不计入峰值)", deflate ? "deflate" : "stored", e0 ? (long long)e0->uncomp_size / 1024 : 0LL, bytes_old / 1024, old_page_peak / 1024, (unsigned long long)ms_old, - 4LL, bytes_new / 1024, new_peak, (unsigned long long)ms_new); + (long long)probe_len, bytes_new / 1024, new_peak, + (unsigned long long)ms_new); CHECK(meta_ok, "[%s] 500 页 meta 全部探测成功", deflate ? "deflate" : "stored"); CHECK(dim_ok, "[%s] 500 页尺寸都取到 96x96", deflate ? "deflate" : "stored"); CHECK(new_peak <= 4096, @@ -866,8 +875,11 @@ int main(void) CHECK(old_page_peak >= 16 * 1024, "[%s] 对照:旧路径每页分配 ≥16KB(实测 %zuB)", deflate ? "deflate" : "stored", old_page_peak); - CHECK(bytes_new < bytes_old / 4, - "[%s] 前缀读总字节 < 旧路径 1/4(%lldkB vs %lldkB)", + CHECK(probe_rc == MZ_OK && probe_len == 4096, + "[%s] mz_read_prefix 实测只读 4KB(rc=%d, %zuB/页)", + deflate ? "deflate" : "stored", probe_rc, probe_len); + CHECK(bytes_new * 4 < bytes_old, + "[%s] 前缀实测总字节 < 旧路径 1/4(%lldkB vs %lldkB)", deflate ? "deflate" : "stored", bytes_new / 1024, bytes_old / 1024); mr_book_close_auto(&zb, &zh); } diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 74093c6..5750658 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -20,6 +20,8 @@ * a) 565 blit 与 RGBA blit 在 565 屏上**逐像素一致**(同一套量化 → 不引入色偏); * b) 565 量化误差在理论上界内(R/B ≤7、G ≤3),并给出 RMSE 与代表性采样; * c) 8bit 灰阶梯/肤色渐变单调、无新增色带(与 RGBA 路径的阶梯数完全相同)。 + * P13(M32 评审 P1):真机主力几何 480×853 —— 单页按 565 原尺寸落槽(~800KB)、 + * 绘制走原生 blit、零重采样(plan_decode_long 曾仍按 4B/px 把它压到 359×639)。 * * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 */ @@ -499,6 +501,45 @@ int main(void) /* ---- P12(M32):565 页槽的颜色正确性(需要 565 屏的 back_buf)---- */ color_check(); + /* ---- P13(M32 评审 P1):真机主力几何 480×853 —— 565 下必须**原尺寸落槽 + 单次 blit** ---- + * 回归点:plan_decode_long 曾仍按 4B/px 判单页上限,把 480×853 压到 359×639(448KB), + * 于是每一帧都走 meow_gfx_blit_rgb565_scaled(重采样)。断言同时看字节与绘制路径, + * 光校验常量(page_px_cap()==786432)抓不到这个执行侧回归。 */ + { + char book4[MR_PATH_MAX], p4[MR_PATH_MAX]; + snprintf(book4, sizeof book4, "./mr_perf_books_%ld/v4", (long)getpid()); + snprintf(p4, sizeof p4, "%s/t853.bmp", book4); + int ok4 = mkdir_p(book4) == 0 && write_bmp24(p4, 480, 853, 11) == 0; + if (!ok4) { + printf(" SKIP: 480x853 fixture write failed\n"); + } else { + meow_view_page_pop(); /* 关掉 600×960 那册 */ + mr_reader_open_dir(book4); + pump_ms(900); + draw_once(); + int p13 = mr_reader_ui_page(); + long slot = mr_reader_ui_slot_kb(p13); + /* 480×853 = 409,440px × 2B = 818,880B = 799.6KB(截断 799;真机口径 ~800KB)。 + * 按 4B/px 误算时会被压到 359×639 = 448KB。 */ + printf(" info: 480x853 resident=%ldKB slot=%ldKB blit=%d scaled=%d\n", + mr_reader_ui_resident_kb(), slot, + mr_reader_ui_blit_draws(), mr_reader_ui_scaled_draws()); + CHECK(mr_reader_ui_count() == 1, "480x853 单页册 count=%d", mr_reader_ui_count()); + CHECK(slot == 480 * 853 * 2 / 1024, + "480×853 按 565 原尺寸落槽:%ldKB(约 800KB;误按 4B/px 时只有 448KB)", slot); + CHECK(mr_reader_ui_resident_kb() == slot, + "单页册常驻 = 槽内字节(%ldKB)", mr_reader_ui_resident_kb()); + int bl13 = mr_reader_ui_blit_draws(), sc13 = mr_reader_ui_scaled_draws(); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > bl13, + "480×853 页绘制走原生 565 blit(+%d)", + mr_reader_ui_blit_draws() - bl13); + CHECK(mr_reader_ui_scaled_draws() == sc13, + "480×853 页零重采样(scaled 增量 %d)", + mr_reader_ui_scaled_draws() - sc13); + } + } + /* ---- P9 800x1280(评审表格另一档):翻页 + 条漫都必须零重采样 ---- */ meow_view_page_pop(); mr_reader_open_dir(book2); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 4b073b3..df97d55 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -76,7 +76,10 @@ #define MR_TICK_MS 16 #define MR_PAINT_MS 16 /* 跟手拖动重绘节流(≈60fps 上限) */ #define MR_DRAG_SLOP 8 /* 小于此位移算点击 */ -#define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) /* 缩放临时缓冲软上限(>此值降倍率,保内存) */ +/* 缩放倍率软上限(M32:绘制已改 meow_gfx_blit_rgb565_scaled,原地采样不再 malloc 目标尺寸 + * 临时图;这里保留原保守系数——按 4B/px 估「放大后解码目标」的峰值,比 565 口径更严, + * 行为不回退。>此值逐档降倍率,保内存。 */ +#define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) #define MR_BOOK_PULL 110 /* 条漫到顶/底后再拉这么远,松手进相邻册 */ #define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ #define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ @@ -460,8 +463,10 @@ static void log_mem(const char *tag) /* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ /* ============================================================ */ /* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit), - * 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。 - * 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */ + * 只有单页缓存字节上限(MR_PAGE_BYTES_CAP,565 口径 = 2B/px)是硬上界——超长条漫在这里按比例收缩。 + * 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 + * M32 评审 P1:这里必须按 565 的 2B/px 算——乘 4 会把真机 480×853 页压到 359×639 + * (每帧重采样),等于把整单的收益吃掉。 */ static int plan_decode_long(int want_w, int want_h) { int l0 = want_w > want_h ? want_w : want_h; @@ -470,7 +475,7 @@ static int plan_decode_long(int want_w, int want_h) int l = l0 < 64 ? 64 : l0; while (l > 64) { int s = (int)((long long)s0 * l / l0); - if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break; + if ((long long)l * s * 2 <= MR_PAGE_BYTES_CAP) break; l = l * 3 / 4; } return l; @@ -497,8 +502,9 @@ static void plan_box_size(int want_w, int want_h, int *bw, int *bh) } /* 固定字段的解码日志:page/src/peak/path 之后是结论,便于 `grep 'reader decode'` - * 后按列看曲线。peak = reader_estimate_peak 的预估峰值(字节/kB);path = 实际解码路径 - * (backend = M23 真缩解后端,stb = 回退旧路径)。 */ + * 后按列看曲线。peak = 本次「取页」预算口径 = reader_estimate_peak 的解码峰值 + * + 落槽 565 暂存(M32:ok/fail/skip 三种结论同一列,便于和 --wrap malloc 实测对照); + * path = 实际解码路径(backend = M23 真缩解后端,stb = 回退旧路径)。 */ static void log_decode(int page, int src_w, int src_h, long long peak_bytes, long memfree, long memavail, const char *verdict, int step, int backend, unsigned long long ms) @@ -577,7 +583,9 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) &used_backend) : meow_img_load_file_dim(path, dim); if (!im) { - log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail", + /* peak 列 = 本次「取页」口径:解码峰值 + 落槽 565 暂存(与 ok/skip 同一列口径)。 */ + log_decode(page, have ? in.w : 0, have ? in.h : 0, + dec.peak_bytes + dec.convert_bytes, mfree, mavail, "fail", dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; @@ -596,7 +604,8 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) meow_img *fit = meow_img_resample(im, box_w, box_h); if (fit) { meow_img_free(im); im = fit; } } - log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "ok", + log_decode(page, have ? in.w : 0, have ? in.h : 0, + dec.peak_bytes + dec.convert_bytes, mfree, mavail, "ok", dec.step, used_backend, (unsigned long long)(meow_ui_time_ms() - t0)); return im; From db92cbd95a8519e569231fc9c1e1a3d87332d5e2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 19:17:29 +0800 Subject: [PATCH 37/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=20M32=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P2=EF=BC=88=E9=A2=84=E7=AE=97=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E6=8C=89=203=20=E6=A7=BD=E5=8F=A3=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E7=BA=AF=E6=B3=A8=E9=87=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reader.c 头注释与页缓存注释:真机 480×853 页「单页 1599KB → 800KB(−50%); 3 槽常驻 3.2MB → 2.4MB」,不再是「两页 1.6MB」。 - MR_RESIDENT_CAP 注释:典型稳态 3 槽 = 2.4MB(窗口只落 2 页时 1.6MB),上限 3.2MB。 - 无判定/常量/代码改动(git diff 只有注释行)。 --- tuku/ui/reader/reader.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index df97d55..9679819 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -17,8 +17,8 @@ * 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 * 内存(M17 起;M22 按真机实测重定;M27 接真缩解后端;M32 页槽改 RGB565): * 槽里常驻的是**已落框的 565 位图**(2B/px),单页上限 1.5MB(旧 RGBA 3MB 的同几何口径)、 - * 整窗常驻上限 3.2MB(3 槽 + 1 张在途,超限淘汰最远页)。真机 480×853 页 1599KB → 800KB, - * 两页常驻 3.2MB → 1.6MB。解码仍产出 RGBA,worker 当场转 565 再释放 RGBA;这笔暂存 + * 整窗常驻上限 3.2MB(3 槽 + 1 张在途,超限淘汰最远页)。真机 480×853 页单页 1599KB → 800KB + * (−50%);3 槽常驻 3.2MB → 2.4MB。解码仍产出 RGBA,worker 当场转 565 再释放 RGBA;这笔暂存 * (2B/px)由 reader_estimate_peak 的 convert_bytes 计入预算。解码路径与峰值预算由 * page_source 的 reader_estimate_peak() * 统一判定(唯一实现,本文件不再另写模型): @@ -97,7 +97,7 @@ #define MR_SLOT_BYTES (800L * 1024) /* 真机典型页(480×853)的 565 字节 */ /* 常驻总上限 = 3 槽 + 1 张「已解码未落槽」的在途结果 = 4 × 800KB = 3.2MB。 * 旧口径 3MB 只算槽内 RGBA(3 槽最坏 4.8MB,且在途结果完全没算);565 后把在途结果 - * 一起算进来,真实上限反而收紧。典型稳态(2 页)1.6MB,只有上限的一半。 */ + * 一起算进来,真实上限反而收紧。典型稳态 3 槽 = 2.4MB(窗口只落 2 页时 1.6MB),上限 3.2MB。 */ #define MR_RESIDENT_CAP (4 * MR_SLOT_BYTES) #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ @@ -164,7 +164,7 @@ static int g_fail_paint; /* 窗口内出现解码失败页 → tick /* ---- 页图缓存(≤3 槽,RGB565 常驻)---- * 槽里存的是**已落框**的 565 位图(2B/px),不是解码器的 RGBA:真机 480×853 页 - * 1599KB → 800KB,两页常驻 3.2MB → 1.6MB。命中页绘制 = 每行一次 memcpy(M32)。 */ + * 单页 1599KB → 800KB(−50%),3 槽常驻 3.2MB → 2.4MB。命中页绘制 = 每行一次 memcpy(M32)。 */ typedef struct page_bmp { int w, h; uint16_t *px; /* w*h,行优先,RGB565(不透明) */ From c5985b2c67a38a7bb3c5012b2f171d0985ff0542 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 19:22:16 +0800 Subject: [PATCH 38/38] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E5=90=8E=E9=A1=B6=E6=A0=8F=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=8C=E7=BB=AD=E8=AF=BB=E3=80=8D=EF=BC=8C=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=9B=9E=E5=88=B0=E4=B8=8A=E6=AC=A1=E9=82=A3=E9=A1=B5?= =?UTF-8?q?=EF=BC=88M33=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:文件夹拆开可进之后仍保留进度,但入口只在**进入了该文件夹**之后才出现, 不在上层列表的文件夹行加角标/按钮。 - ui/library/library.c:顶栏按 mr_cont_resume_page(g_cur_dir, 图片数) 判定,有进度才加 MEOW_ICON_PLAY_ARROW「续读」动作(label 保留为语义名);点按 = mr_reader_open_dir_at (g_cur_dir, MR_READER_START_RESUME)。SDK 的 TopAppBar 动作创建期固定、无隐藏 API, 因此可见性变化时整条顶栏重建:无进度是真的 2 个动作(没有空占位/死点击区), 续读插最左,刷新/设置的下标与命中区不变。 - 可见性实时重判:load_dir(进入/返回/刷新)与 library_page_enter(从阅读器返回, 进度可能刚写盘)都调 resume_refresh;根目录有进度时同样显示。 - logic/reader.c/.h:新增 mr_cont_resume_page(dir, pages)——无记录/空目录 → -1(不显示); 保存页超页数(文件被删)→ 钳到末页并 INFO 日志;页数未知(列表被 2000 截断)不夹持, 上界交给阅读器 open_book 自己的 mr_nav_clamp_page。 - 不改列表行语义(不重新引入 M18 删掉的「目录自动当书」)。 - 宿主回归(test_browse_ui_host U8):有进度目录 3 个动作/无进度 2 个、三个动作的点击区 互不遮挡、点续读从当前目录续读页打开、超页数钳到末页、空目录不显示、上层/进入/返回 与阅读器返回后的可见性切换;test_library T5 覆盖 mr_cont_resume_page 的全部边界。 --- tuku/logic/reader.c | 16 ++++ tuku/logic/reader.h | 9 +++ tuku/test/test_browse_ui_host.c | 115 ++++++++++++++++++++++++++++ tuku/test/test_library.c | 38 ++++++++- tuku/ui/library/library.c | 131 ++++++++++++++++++++++++++++---- 5 files changed, 294 insertions(+), 15 deletions(-) diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c index 44d633c..d466851 100644 --- a/tuku/logic/reader.c +++ b/tuku/logic/reader.c @@ -7,6 +7,8 @@ * 之后走纯内存快路径(不再读配置)。 * M7:阅读模式(翻页/条漫)与每册 last page 同为 meow_config 小键(reader.mode/page.<册标识>, * 册标识=规范化目录路径的 FNV-1a64);书库行角标经 mr_cont_badge 读取同一份状态。 + * M33:mr_cont_resume_page 给「进入目录后的顶栏续读按钮」做可用性判定与页码夹持 + * (保存页超出当前页数 → 钳到末页;空目录/无图片 → 无入口)。 * 依赖面:只 include 配置/日志两个模块头(不含 UI);宿主测试用 * test/settings_reader_stub.c 顶替 meow_config_* 与 meow_log。 */ @@ -182,3 +184,17 @@ int mr_cont_badge(const char *dir, char *out, int outsz) snprintf(out, (size_t)outsz, "续读 P%d", page + 1); return 1; } + +int mr_cont_resume_page(const char *dir, int pages) +{ + int page = mr_cont_get_page(dir, -1); + if (page < 0) return -1; + if (pages == 0) return -1; /* 空目录/无图片:没有可续读的页 */ + if (pages < 0) return page; /* 页数未知(列表截断):上界交给阅读器钳制 */ + if (page >= pages) { + meow_log(MEOW_LOG_INFO, "reader: resume page %d beyond %d pages (%s), clamp to last", + page + 1, pages, dir ? dir : ""); + return pages - 1; + } + return page; +} diff --git a/tuku/logic/reader.h b/tuku/logic/reader.h index fd62fcc..a548885 100644 --- a/tuku/logic/reader.h +++ b/tuku/logic/reader.h @@ -48,6 +48,15 @@ void mr_cont_save(const char *dir, int page, int mode); /* 书库行续读角标:1=写入 "续读 P5"(page>0 才有记录);0=无。 */ int mr_cont_badge(const char *dir, char *out, int outsz); +/* 目录内「续读」入口的可用页(M33:按钮在**进入目录后**的顶栏,不在上层列表行)。 + * pages > 0:该目录当前图片页数;保存页 >= pages(文件被删/目录变短)→ 钳到 pages-1 + * 并 INFO 日志注明,保证「续读」永远落在有效页; + * pages == 0:空目录/无图片 → 恒 -1(入口不显示); + * pages < 0:页数未知(书库列表被 MR_LIB_SCAN_MAX 截断)→ 有记录就原样返回, + * 上界交给阅读器 open_book 的 mr_nav_clamp_page 钳制。 + * 返回 -1 = 当前目录没有可用的续读记录(不显示入口)。 */ +int mr_cont_resume_page(const char *dir, int pages); + #ifdef __cplusplus } #endif diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index cf56ba6..3caf6e5 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -13,6 +13,9 @@ * 不弹错误横幅;顶栏刷新/重扫可重试成功; * U6 子目录不可读:停留原目录 + 顶部提示横幅; * U7 书库不再碰缓存目录:CBZ 解压页缓存 mrpage_* 原样保留,且不写任何 bmp。 + * U8 目录内续读入口(M33):顶栏「续读」只在当前所在目录有进度时出现(上层列表行 + * 不加角标),动作数 3/2 随导航切换;点按从续读页打开当前目录;保存页超页数 + * 钳到末页;空目录/无进度不显示;从阅读器返回后重新判定。 * * 编译:见 test/run_tests.sh(链 SDK 源码树 + 打桩;只发布 meow_ui.a 的环境自动 SKIP)。 * ui/library/library.c 以 -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;logic/library.c @@ -47,6 +50,16 @@ int library_test_retry(void); int library_test_go_up(void); int library_test_drag_active(void); int library_test_has_list(void); +int library_test_topbar_actions(void); +int library_test_resume_page(void); +int library_test_topbar_hit(int x, int y); + +/* 顶栏命中枚举(与 ui/library/library.c 的 LIB_TB_* 同值)。 */ +#define LIB_TB_NONE 0 +#define LIB_TB_BACK 1 +#define LIB_TB_RESUME 2 +#define LIB_TB_REFRESH 3 +#define LIB_TB_SETTINGS 4 /* 阅读器入口打桩:记录被打开的册路径 / 起始页 / 按名定位的文件名(本测试不建 reader 页)。 */ #define MR_READER_START_RESUME (-1) @@ -416,6 +429,108 @@ int main(void) "library writes no bmp to cache (pages=%d thumbs=%d)", pages_after, thumbs_after); } + /* ---- U8:目录内续读入口(M33) ---- + * 顶栏「续读」只在**已进入的目录本身**有阅读进度时出现(不在上层列表的文件夹行 + * 加角标/按钮——用户明确要求「进入了该文件夹才有显示」)。点按 = 从续读页打开 + * 当前目录;保存页超出当前图片数(文件被删)→ 钳到末页;空目录/无进度不显示。 */ + const int tby = meow_ui_statusbar_height() + 28; + char empty_dir[MR_PATH_MAX]; + joinp(empty_dir, sizeof empty_dir, root, "empty"); + /* 顶栏宽 480、动作从右往左各 48px:设置最右 [422,470)、刷新 [374,422)、 + * 续读(有进度时)[326,374)。 */ + const int x_resume = 350, x_refresh = 395, x_settings = 440; + + /* 根没有进度:只有刷新+设置两个动作,续读格不存在(不是置灰占位)。 */ + CHECK(library_test_load(root) == 1, "U8 load root"); + CHECK(library_test_topbar_actions() == 2, "no-progress dir has 2 actions (got %d)", + library_test_topbar_actions()); + CHECK(library_test_resume_page() == -1, "no resume page without progress"); + CHECK(library_test_topbar_hit(x_resume, tby) == LIB_TB_NONE, + "no resume hit cell when hidden"); + CHECK(library_test_topbar_hit(x_refresh, tby) == LIB_TB_REFRESH, + "refresh cell intact without resume"); + CHECK(library_test_topbar_hit(x_settings, tby) == LIB_TB_SETTINGS, + "settings cell intact without resume"); + + /* album 有 2 张图;写入进度 P2(page=1)后进入 → 3 个动作。 */ + mr_cont_set_page(album_dir, 1); + CHECK(library_test_load(album_dir) == 1, "U8 load album"); + CHECK(library_test_topbar_actions() == 3, "dir with progress has 3 actions (got %d)", + library_test_topbar_actions()); + CHECK(library_test_resume_page() == 1, "resume page=%d (want 1)", + library_test_resume_page()); + CHECK(library_test_topbar_hit(x_resume, tby) == LIB_TB_RESUME, "resume cell hits"); + CHECK(library_test_topbar_hit(x_refresh, tby) == LIB_TB_REFRESH, + "refresh still hits with 3 actions"); + CHECK(library_test_topbar_hit(x_settings, tby) == LIB_TB_SETTINGS, + "settings still hits with 3 actions"); + CHECK(library_test_topbar_hit(20, tby) == LIB_TB_BACK, "back cell still hits"); + + /* 点续读 = 从当前目录的续读页打开(阅读器按自己的全量目录册再夹持一次)。 */ + g_opened_n = 0; + g_opened[0] = '\0'; + g_opened_name[0] = '\0'; + g_opened_start = 12345; + library_on_tap(x_resume, tby); + CHECK(g_opened_n == 1 && strcmp(g_opened, album_dir) == 0, + "tap resume opens current dir (%s)", g_opened); + CHECK(g_opened_start == MR_READER_START_RESUME, "resume tap uses resume start (%d)", + g_opened_start); + CHECK(strcmp(library_test_dir(), album_dir) == 0, "resume tap does not navigate"); + + /* 顶栏刷新仍重扫当前目录(下拉/刷新后可见性保持)。 */ + char extra[MR_PATH_MAX]; + joinp(extra, sizeof extra, album_dir, "003.png"); + touch_file(extra); + library_on_tap(x_refresh, tby); + CHECK(library_test_rows() == 3, "refresh rescans dir after resume (rows=%d)", + library_test_rows()); + CHECK(library_test_topbar_actions() == 3, "resume survives refresh"); + unlink(extra); + + /* 返回上层:上层没有这层的进度 → 续读隐藏;再进入 → 重新出现。 */ + CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), root) == 0, "up to root"); + CHECK(library_test_topbar_actions() == 2, "resume hidden in parent without progress"); + CHECK(library_test_topbar_hit(x_resume, tby) == LIB_TB_NONE, "resume cell gone in parent"); + CHECK(library_test_load(album_dir) == 1, "re-enter album"); + CHECK(library_test_topbar_actions() == 3, "resume back after re-enter"); + + /* 根也是目录:根有进度时同样显示(root.png 一页,page 0 是有效记录)。 */ + mr_cont_set_page(root, 0); + CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), root) == 0, "up to root again"); + CHECK(library_test_topbar_actions() == 3, "root shows resume when it has progress"); + CHECK(library_test_resume_page() == 0, "root resume page=%d (want 0)", + library_test_resume_page()); + + /* 边界:保存页超出当前图片数(文件被删)→ 钳到末页(2 页 → 下标 1),仍显示。 */ + mr_cont_set_page(album_dir, 99); + CHECK(library_test_load(album_dir) == 1, "load album with stale page"); + CHECK(library_test_resume_page() == 1, "stale page clamps to last (got %d)", + library_test_resume_page()); + CHECK(library_test_topbar_actions() == 3, "clamped resume still visible"); + + /* 空目录:即使有残留记录也不显示(没有可续读的页)。 */ + mr_cont_set_page(empty_dir, 2); + CHECK(library_test_load(empty_dir) == 1, "load empty dir"); + CHECK(library_test_resume_page() == -1, "empty dir has no resume entry"); + CHECK(library_test_topbar_actions() == 2, "empty dir hides resume (got %d)", + library_test_topbar_actions()); + + /* 从阅读器返回后重判:Pictures 本来没有进度 → 2;阅读器离开时写入进度后, + * 走真实 view 生命周期(push reader → pop)→ library on_enter 重判为 3。 */ + CHECK(library_test_load(pics_dir) == 1, "load Pictures without progress"); + CHECK(library_test_topbar_actions() == 2, "Pictures initially 2 actions"); + mr_cont_set_page(pics_dir, 2); /* 模拟阅读器离开时刚写盘 */ + meow_view *faux_reader = meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, NULL, NULL); + meow_view_page_push(ACT_READER); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back on library after reader"); + CHECK(library_test_topbar_actions() == 3, "resume appears after reader return (got %d)", + library_test_topbar_actions()); + CHECK(library_test_resume_page() == 2, "resume page after return=%d", + library_test_resume_page()); + meow_view_free(faux_reader); + meow_view_free(lib); meow_ui_shutdown(); rm_rf(data); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index bf04cc9..e9ca15f 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -6,7 +6,9 @@ * T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序); * 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定); * 行类型;不可读路径 -> -1;max_depth 忽略;浏览根前缀判据 mr_path_within_root; - * T4 阅读方向状态(logic/reader.c)。 + * T4 阅读方向状态(logic/reader.c); + * T5 目录内续读入口的页码判定(mr_cont_resume_page):无记录/空目录 -> 无入口, + * 页数未知(列表截断)不夹持,保存页超页数钳到末页,记录按目录隔离。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk test/test_library.c logic/library.c logic/reader.c \ @@ -15,6 +17,7 @@ */ #include "logic/library.h" #include "logic/reader.h" +#include "test/settings_reader_stub.h" #include #include @@ -272,6 +275,38 @@ static void test_direction(void) mr_reader_set_direction(MR_DIR_RTL); } +/* 目录内「续读」入口的页码判定(M33)。 */ +static void test_cont_resume(void) +{ + const char *dir = "/sdcard/album"; + const char *other = "/sdcard/other"; + mr_test_cfg_clear(); + + CHECK(mr_cont_resume_page(dir, 5) == -1, "no record -> no resume entry"); + + mr_cont_set_page(dir, 3); + CHECK(mr_cont_resume_page(dir, 5) == 3, "page in range kept"); + CHECK(mr_cont_resume_page(dir, 4) == 3, "last valid page kept"); + CHECK(mr_cont_resume_page(dir, 0) == -1, "empty dir -> no resume entry"); + CHECK(mr_cont_resume_page(dir, -1) == 3, + "unknown page count (truncated list) -> unclamped"); + + CHECK(mr_cont_resume_page(dir, 3) == 2, "page == pages -> clamp to last"); + mr_cont_set_page(dir, 99); + CHECK(mr_cont_resume_page(dir, 3) == 2, "page beyond pages -> clamp to last"); + CHECK(mr_cont_resume_page(dir, 0) == -1, "stale record in empty dir -> no entry"); + + CHECK(mr_cont_resume_page(other, 9) == -1, + "resume records are per directory (no leak)"); + CHECK(mr_cont_resume_page(NULL, 9) == -1, "NULL dir -> no entry"); + + /* 第一页也是有效记录(用户就停在第一页退出):入口要显示。 */ + mr_cont_set_page(dir, 0); + CHECK(mr_cont_resume_page(dir, 1) == 0, "first page record is a valid entry"); + + mr_test_cfg_clear(); +} + int main(void) { test_natural(); @@ -279,6 +314,7 @@ int main(void) test_parent(); test_scan(); test_direction(); + test_cont_resume(); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); return g_fail ? 1 : 0; } diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 41bac1e..eddd2d3 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,6 +1,7 @@ /* ui/library/library.c — 图库浏览页(ACT_LIBRARY):文件管理器模型。 * - * TopAppBar(标题 + 返回 + 刷新 + ⚙)+ meow_listview(src2 行数据源)。 + * TopAppBar(标题 + 返回 + [续读] + 刷新 + ⚙)+ meow_listview(src2 行数据源); + * [续读] 只在当前目录有阅读进度时出现(见下「目录内续读入口」)。 * * 浏览语义(M18,用户要求「完全像文件管理器」): * - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序; @@ -14,6 +15,19 @@ * - 根目录不可访问(未插卡/挂载失败)→ 列表空态「未检测到 SD 卡」+ 顶栏刷新重试; * 重进本页也会自动重扫。子目录不可读 → 停留原目录 + 顶部提示横幅。 * + * 目录内续读入口(M33,用户要求):顶栏在**当前所在目录有阅读进度**时多一个「续读」动作 + * (MEOW_ICON_PLAY_ARROW),点按 = mr_reader_open_dir_at(g_cur_dir, MR_READER_START_RESUME) + * 直接回到上次退出那页。定位是**进入该文件夹之后才有**——不在上层列表的目录行上加角标、 + * 按钮或「目录自动当书」语义(M18 明确删掉的模型,勿 reintroduce):上层列表仍然只是 + * 文件管理器行,只有真进来了才知道这层有没有进度。 + * 可见性随导航实时重判:load_dir(进入/返回/下拉刷新)与 library_page_enter(从阅读器返回, + * 进度可能刚被写入)都调 resume_refresh;根目录也是目录,有进度时同样显示。 + * 顶栏动作是创建期固定的(SDK 无「动态隐藏 action」API),因此可见性变化时整条顶栏重建: + * 无进度时是真的 2 个动作(刷新/设置),没有空占位或死点击区;「续读」插在最左, + * 刷新/设置的下标与命中区不变。 + * 边界:保存页超出当前目录图片数(文件被删)→ 钳到末页并 INFO 日志(mr_cont_resume_page); + * 空目录/无图片/目录不可读 → 不显示入口。 + * * 行绑定(src2 模式):meow_listview_create(..., lib_src, ...) 每帧对可见行取一次行数据; * lib_src 只读 g_lib 内存(指针直出,不做 IO/格式化),滚动与点击路径零解码零文件访问。 * @@ -39,8 +53,11 @@ static meow_widget *g_tb; static meow_widget *g_lv; static meow_scroll *g_scroll; static int g_list_x, g_list_y, g_list_w, g_list_h; +static int g_act_resume = -1; /* 顶栏「续读」(-1=当前目录无进度,动作不存在) */ static int g_act_refresh = -1; static int g_act_settings = -1; +static int g_resume_on; /* 1=当前目录有可用续读记录(顶栏 3 个动作) */ +static int g_resume_page = -1; /* 「续读」将打开的页(已夹持;-1=无入口) */ static int g_tb_back; /* 顶栏返回箭头是否显示(根目录隐藏) */ static int g_sd_missing; /* 1=根目录不可访问(未检测到 SD 卡的空态) */ @@ -152,6 +169,57 @@ static void update_topbar(void) meow_topappbar_set_back(g_tb, g_tb_back); } +/* 重建顶栏:动作集由 g_resume_on 决定(3=含续读 / 2=只有刷新+设置)。 + * SDK 的 TopAppBar 动作是 add_action 创建期固定、没有隐藏/删除 API,所以用「重建」实现 + * 真正的按需显示(不留空占位、不留 12px 死点击区,动作数就是 2 或 3)。 + * 续读放最左:刷新/设置的下标仍是倒数第二/最后一个,命中区与改动前完全一致。 */ +static void topbar_build(void) +{ + int sb = meow_ui_statusbar_height(); + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + g_act_resume = g_act_refresh = g_act_settings = -1; + + g_tb = meow_topappbar_create(0, sb, meow_ui_width(), MR_TOPAPPBAR_H, "图库"); + if (!g_tb) return; + if (g_resume_on) { + /* label "续读" 在被 set_item_icon 转位图后不绘制,但保留为该动作的语义名。 */ + g_act_resume = meow_topappbar_add_action(g_tb, "续读"); + if (g_act_resume >= 0) + meow_topappbar_set_item_icon(g_tb, g_act_resume, MEOW_ICON_PLAY_ARROW); + } + g_act_refresh = meow_topappbar_add_action(g_tb, ""); + if (g_act_refresh >= 0) meow_topappbar_set_item_icon(g_tb, g_act_refresh, MEOW_ICON_REFRESH); + g_act_settings = meow_topappbar_add_action(g_tb, ""); + if (g_act_settings >= 0) meow_topappbar_set_item_icon(g_tb, g_act_settings, MEOW_ICON_SETTINGS); + update_topbar(); +} + +/* 当前目录的图片页数(与阅读器目录册同一套单层判据:受支持图片、非隐藏、普通文件)。 + * -1=列表被 MR_LIB_SCAN_MAX 截断 → 真实页数 >= 已列出数,未知;此时不做上界夹持, + * 交给阅读器 open_book(mr_nav_clamp_page)按它自己的全量枚举钳制。 */ +static int cur_page_count(void) +{ + if (g_lib.truncated) return -1; + int n = 0; + for (int i = 0; i < g_lib.count; i++) + if (g_lib.items[i].kind == MR_LIB_KIND_IMAGE) n++; + return n; +} + +/* 重新判定「续读」入口(load_dir / 从阅读器返回 / 下拉刷新后都走这里)。 + * 只在当前所在目录有可用续读时显示;无进度/空目录/无图片 → 隐藏(零占位)。 */ +static void resume_refresh(void) +{ + g_resume_page = mr_cont_resume_page(g_cur_dir, cur_page_count()); + int on = (g_resume_page >= 0); + if (!g_tb || on != g_resume_on) { + g_resume_on = on; + topbar_build(); + } else { + update_topbar(); + } +} + static void list_invalidate(void) { meow_ui_invalidate(g_list_x, g_list_y, g_list_w, g_list_h); @@ -221,7 +289,7 @@ static int load_dir(const char *dir) snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); g_sd_missing = 1; list_rebuild(); - update_topbar(); + resume_refresh(); /* SD 卡空态:无列表/无图片 → 续读入口隐藏 */ repaint_all(); meow_log(MEOW_LOG_INFO, "library: storage root unavailable: %s (errno=%d %s)", target, errno, strerror(errno)); @@ -243,7 +311,7 @@ static int load_dir(const char *dir) g_sd_missing = 0; list_rebuild(); - update_topbar(); + resume_refresh(); /* 进入/返回/刷新都重判:当前目录有进度才显示续读 */ g_drag = 0; g_dragged = 0; repaint_all(); @@ -312,6 +380,12 @@ void library_on_tap(int x, int y) if (g_tb) { int hit = meow_topappbar_hit(g_tb, x, y); if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (g_act_resume >= 0 && hit == g_act_resume) { + /* 一键回到本目录上次退出那页:续读页由阅读器按自己的全量目录册再夹持一次。 */ + if (g_resume_page >= 0) + mr_reader_open_dir_at(g_cur_dir, MR_READER_START_RESUME); + return; + } if (g_act_refresh >= 0 && hit == g_act_refresh) { refresh_current(); return; } if (g_act_settings >= 0 && hit == g_act_settings) { meow_view_page_push(ACT_SETTINGS); @@ -404,15 +478,9 @@ static void library_page_create(meow_view *v, void *ctx) (void)v; (void)ctx; - int w = meow_ui_width(); - int sb = meow_ui_statusbar_height(); - g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); - if (g_tb) { - g_act_refresh = meow_topappbar_add_action(g_tb, ""); - if (g_act_refresh >= 0) meow_topappbar_set_item_icon(g_tb, g_act_refresh, MEOW_ICON_REFRESH); - g_act_settings = meow_topappbar_add_action(g_tb, ""); - if (g_act_settings >= 0) meow_topappbar_set_item_icon(g_tb, g_act_settings, MEOW_ICON_SETTINGS); - } + /* 启动时还不知道根目录有没有进度:先按「无续读」建顶栏(2 个动作), + * load_dir 里的 resume_refresh 会按实际可见性决定是否补上「续读」动作。 */ + topbar_build(); hint_place(); g_hint_id = meow_draw_custom_register(MEOW_Z_TOP, g_hint_x, g_hint_y, @@ -446,7 +514,9 @@ static void library_page_enter(meow_view *v, void *ctx) g_dragged = 0; /* 无 SD 卡空态:重进页面即重扫(用户要求的可重试路径之一)。 */ if (g_sd_missing) load_dir(g_cur_dir); - update_topbar(); + /* 从阅读器/设置页返回:进度可能刚被阅读器写盘(离开阅读页时 cont_save_now), + * 因此每次进页都重判「续读」可见性(幂等;无变化时只刷新标题/返回箭头)。 */ + resume_refresh(); mr_nav_report(); meow_ui_paint_full(); } @@ -468,7 +538,9 @@ static void library_page_destroy(meow_view *v, void *ctx) if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } - g_act_refresh = g_act_settings = -1; + g_act_resume = g_act_refresh = g_act_settings = -1; + g_resume_on = 0; + g_resume_page = -1; mr_library_free(&g_lib); } @@ -549,6 +621,37 @@ int library_test_topbar_back(void) return g_tb_back; } +/* 顶栏动作数:2=刷新+设置(当前目录无进度),3=再加上「续读」。 */ +int library_test_topbar_actions(void) +{ + return (g_act_resume >= 0 ? 1 : 0) + (g_act_refresh >= 0 ? 1 : 0) + + (g_act_settings >= 0 ? 1 : 0); +} + +/* 「续读」将打开的页(已按当前页数夹持;-1=没有入口)。 */ +int library_test_resume_page(void) +{ + return g_resume_page; +} + +/* 顶栏命中(走真实 meow_topappbar_hit):0=无 1=返回 2=续读 3=刷新 4=设置。 + * 用于断言三个动作的点击区互不遮挡、且无进度时续读区不存在。 */ +#define LIB_TB_NONE 0 +#define LIB_TB_BACK 1 +#define LIB_TB_RESUME 2 +#define LIB_TB_REFRESH 3 +#define LIB_TB_SETTINGS 4 +int library_test_topbar_hit(int x, int y) +{ + if (!g_tb) return LIB_TB_NONE; + int hit = meow_topappbar_hit(g_tb, x, y); + if (hit == MEOW_TOPBAR_BACK) return LIB_TB_BACK; + if (hit >= 0 && hit == g_act_resume) return LIB_TB_RESUME; + if (hit >= 0 && hit == g_act_refresh) return LIB_TB_REFRESH; + if (hit >= 0 && hit == g_act_settings) return LIB_TB_SETTINGS; + return LIB_TB_NONE; +} + /* 当前列表空态文案(无 SD 卡/空文件夹)。 */ const char *library_test_empty_text(void) {