From 703602000541621f44feb8be7b60c3ad217a83bf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 18:33:41 +0800 Subject: [PATCH] =?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);/* 累计解码次数(查重解抖动/活锁) */