Merge branch 'feat/tuku-page-slot-rgb565-residency'

This commit is contained in:
AskaEth committed 2026-11-06 19:22:56 +08:00
commit f2c489d89b
11 files changed
+986 -119

No files matched your search

+18
View File
@@ -50,6 +50,24 @@ 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:每行一次拷贝,无重采样。
*
* 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); /* 不透明像素(忽略 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 同式)。 */
void meow_gfx_blit_rgb565_scaled(int x, int y, int w, int h,
int sw, int sh, const uint16_t *px);
/* ============================================================ */
/* §7 动画/时间(B1 最小可用) */
/* ============================================================ */
+100
View File
@@ -856,6 +856,106 @@ 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 按不透明处理(页图 alpha 恒为 255;带透明像素的
* PNG/32bpp BMP 页会与旧 RGBA 路径不同——旧路径混合到底色,这里直接画 RGB)。 */
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)
{
+35 -14
View File
@@ -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;
+10 -3
View File
@@ -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;
}
+8 -1
View File
@@ -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;
+62 -6
View File
@@ -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) {
+9
View File
@@ -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 */
+318 -23
View File
@@ -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 <dirent.h>
@@ -39,6 +40,7 @@
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <zlib.h> /* 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,99 @@ 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;
/* 前缀字节数**实测**(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 * (long long)probe_len;
INFO("CBZ 500 页[%s]:旧=整页解压 %lldkB/页·总%lldkB 峰值%zuKB 耗时%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,
(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,
"[%s] 排版期(首帧之后)每页堆峰值 ≤4KB(实测 %zuB)",
deflate ? "deflate" : "stored", new_peak);
CHECK(old_page_peak >= 16 * 1024,
"[%s] 对照:旧路径每页分配 ≥16KB(实测 %zuB)",
deflate ? "deflate" : "stored", old_page_peak);
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);
}
}
/* ---- B7 M27:6 类典型页 —— 全部走后端、不占位、模型 ≥ 实测且 ≤1.5× ---- */
if (have_jpeg) {
static const char *const typical[] = {
@@ -655,6 +923,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 +1043,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();
+213 -1
View File
@@ -15,7 +15,13 @@
* 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 路径的阶梯数完全相同)。
* 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)。
*/
@@ -25,6 +31,7 @@
#include "logic/reader/nav.h"
#include <dirent.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -83,6 +90,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 +207,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 +498,48 @@ 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();
/* ---- 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);
+201 -69
View File
@@ -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
* (−50%);3 槽常驻 3.2MB → 2.4MB。解码仍产出 RGBA,worker 当场转 565 再释放 RGBA;这笔暂存
* (2B/px)由 reader_estimate_peak 的 convert_bytes 计入预算。解码路径与峰值预算由
* page_source 的 reader_estimate_peak()
* 统一判定(唯一实现,本文件不再另写模型):
* - 真缩解后端(M23)能接住的页:峰值只与目标尺寸相关(≈1.3–2.5MB,与源分辨率无关),
* 直接放行,不做内存门控;
@@ -72,21 +76,33 @@
#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 /* 续读状态落盘节流 */
#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 后把在途结果
* 一起算进来,真实上限反而收紧。典型稳态 3 槽 = 2.4MB(窗口只落 2 页时 1.6MB),上限 3.2MB。 */
#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 +162,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(−50%),3 槽常驻 3.2MB → 2.4MB。命中页绘制 = 每行一次 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 +208,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 +266,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 +292,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 +381,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,26 +433,40 @@ 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);
}
/* ============================================================ */
/* 页源解码回调(注入 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;
@@ -392,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;
@@ -419,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)
@@ -481,7 +565,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));
@@ -499,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;
@@ -518,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;
@@ -668,15 +755,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 +798,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 +810,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 +844,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 +951,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 +1002,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 +1049,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 +1092,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 +1105,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 +1281,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 +1293,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 +1318,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 +1613,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 +1870,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 +1953,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 +2629,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 +2645,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)
{
+12 -2
View File
@@ -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);/* 累计解码次数(查重解抖动/活锁) */