Files
MeowOS-For-R3P2/patch_v2/ncm_app/REPORT.md
T
MeowOS 51281e609f docs(ncm §24): 对外名称按约定改「网易云音乐」→「流媒体客户端」(app.json) + REPORT 补注
task 约束:对外文档称「流媒体客户端」,不写「网易云/NetEase」。仅改显示名,package/exec 不变。
2026-09-01 20:01:13 +08:00

189 KiB
Raw Blame History

os.meow.ncm 技术栈预研 + 构建验证报告

日期:2026-08-31。工作区:/home/Aska/Project/R3ProII,仅改动 patch_v2/ncm_app/。 目标:为 MeowOS 网易云 App(v1 = 扫码登录 + 我的歌单 + 播放)在 HiBy R3 Pro II (MIPS32r2 单核、~14MB 可用内存、无 GPU、ALSA 直开 hw:0,0)上的纯 C 静态栈做选型与真机交叉编译验证。 工具链:.tools/zig-mipsel-cc(zig 包装 mipsel-linux-musleabi soft-float 静态交叉)。全程未 commit/push/刷机/动设备。

1. 选型结论表

能力 选型 版本 许可 mipsel 体积(text) 说明
TLS + 加密 mbedtls(裁剪) 3.6.2 Apache-2.0 链接 Demo ≈ 388 KB TLS1.2 client + AES-128-CBC/ECB/GCM + MD5 + RSA(1024 raw) + Base64 + X509,单库全包
TLS(备选) BearSSL master@7bea48e MIT TLS client 子集 ≈ 96 KB 更省 flash,但无 base64、AES-ECB 需自行逐块、RSA raw 用 br_rsa_i31_public
JSON jsmn 1.1.0 MIT 2.2 KB 分词器,无 malloc/无 DOM;cJSON 对比 20 KB
二维码 qrcodegen nayuki@993ccbb MIT 10.8 KB 纯 C 单文件,无图形依赖
MP3 解码 minimp3 master@ea99364 CC0 37.4 KB minimp3_ex 流式/可 seek;dr_mp3 对比 40.7 KB

HTTPS GET/POST 可达性:mbedtls 已实测连通(见 §2)。crypto 是否 mbedtls 自带就够:是—— AES-128-ECB/CBC、MD5、Base64、RSA(raw) 全有;RSA raw 用 mbedtls_rsa_public()(无 padding 纯模幂)已交叉校验与 pow(m,e,n) 完全一致(见 §3),这是文档标注最易踩坑处,已验证可行。

2. HTTPS 实测(宿主 = 本机 aarch64;另跑通 MIPS 版)

demo:demo/https_unikey.c(mbedtls TLS1.2 + eapi 加密 + jsmn)。 目标:POST https://interface.music.163.com/eapi/login/qrcode/unikey,body params=<eapi hex>。

deviceId=68DFA27096B7A5DC2CB393C77D8C194FDC0459B743081D091EBD
---- response body ----
{"code":200,"unikey":"33a1c6a6-28ad-4e1e-bc8e-ebe769953913"}
UNIKEY = 33a1c6a6-28ad-4e1e-bc8e-ebe769953913
  • 宿主:code:200 + unikey 顶层字段,与 NCM_API_NOTES §7 一致 ✅
  • MIPS 版交叉编译后,在 qemu-mipsel 下全链路跑通(DNS→TCP→TLS→POST→JSON 解析),同样拿到真实 unikey: {"code":200,"unikey":"6731b1ac-71a2-41eb-a704-a2de39d6d0b7"}。即 mipsel 静态产物的网络+crypto+TLS 路径正确。
  • eapi 加密逻辑与原版 crypto::eapi 一致:digest=md5("nobody"+url+"use"+json+"md5forencrypt"), plain=url+"-36cd479b6b5-"+json+"-36cd479b6b5-"+digest,params=HEX_UPPER(AES128ECB(plain,"e82ckenh8dichen8")); header(osver/deviceId/os/appver/versioncode/mobilename/buildver/resolution/__csrf/channel/requestId)与 e_r:false 均入加密体(§8 要求,去掉会 4xx)。

3. RSA raw(weapi 无 padding 模幂)校验

mbedtls_rsa_public(ctx, 128B输入右对齐16B明文, 128B输出),n=1024-bit、e=65537。

  • mipsel(qemu) 与宿主输出一致,且与 Python pow(m,e,n) 逐一字节相同(256 hex 完全匹配): CT = 55B33E7E3229B8D977137D120B3C7A5B5FB8E933325CAA5BB8CEF6C132D57E5E...B2B83
  • 结论:mbedtls_rsa_public() 可用作 weapi 的 textbook RSA,避开 PKCS#1 padding 陷阱。demo:demo/rsa_raw_test.c。

4. 总体积账(mipsel,-Os,flash≈text)

选定的库合计(≈438 KB):

项 text 备注
mbedtls TLS+crypto+x509(链接 demo) 388 KB 含 ~6 KB musl libc 基线;库文本合计 410 KB,gc-sections 未再降
jsmn 2.2 KB
qrcodegen 10.8 KB
minimp3 37.4 KB
库合计 ≈ 438 KB 另需 App/UI/封面/音频输出层(未计入,自研)

可选裁剪方向(能明显压 mbedtls):限制密码套件到 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(+1 个 CBC+SHA 兜底)、 ECP 只留 P-256、去掉 ECDSA(服务器给 RSA 证书)、MBEDTLS_SSL_IN/OUT_CONTENT_LEN=4096。估可降到 ~250 KB。 若固件预算仍紧,切 BearSSL(≈96KB) + 自带 base64/AES-ECB 例程,能再省 ~300 KB。

对比参考项体积:cJSON 20 KB、dr_mp3 40.7 KB、BearSSL 全库 310 KB(裁剪子集 96 KB)。空静态 mipsel 基线 text=5888。

5. 内存 / 性能估算(14MB 可用)

  • TLS 握手 buffer:mbedtls 默认 SSL_IN/OUT_CONTENT_LEN=16KB 各一,共 ~32KB;demo 二进制 bss=26888(≈26KB)+堆内 SSL context/ECDH 临时密钥。对 14MB 无压力;建议调成 4096 更省。

  • JSON 解析:jsmn 无 malloc,token 数组按需分配(响应小,几百字节)。不要全量缓冲歌单详情,只取需要的键(文档 §4.4)。

  • 解码:minimp3 流式,输入 ~每帧几 KB + 输出 MINIMP3_MAX_SAMPLES_PER_FRAME*2*ch,整体 <20KB。

  • 封面:必须限幅——NCM 建议 5MB,MeowOS 应限 ~256KB 并降采样/调色板(无 GPU 只能 CPU 软缩放)。

  • 二维码位图:41×41 符号渲染成 UI 尺寸(如 400×400,灰度 1B/px=160KB,1-bit 可 20KB)。

  • 预算结论:TLS+解码+二维码+封面(256KB cap)+UI 帧缓冲,总工作集应在数百 KB 量级,14MB 富余;唯一要盯的是封面/歌单响应别全量驻留。

  • 性能:

    • MP3 解码实时性:宿主(aarch64) minimp3 = 568×RT、dr_mp3 = 627×RT; qemu-mipsel(TCG 模拟,远慢于真机,通常 10–50×) 下 minimp3 = 4.8×RT、dr_mp3 = 4.7×RT。 → 真机 MIPS32 单核跑 44.1k/64kbps 解码余量很大(估计数十倍 RT),解码不是瓶颈。⚠️ 该数字为宿主/模拟估算,标注不准。
    • TLS 握手:ECDHE-RSA-AES128-GCM,单核软 RSA 1024 校验 + ECDH P-256,估几十~几百 ms,可接受(仅首次/重连)。
    • weapi RSA raw 模幂:1024-bit 软模幂每次约几 ms~几十 ms(只对 weapi 请求,频率低)。

6. 风险与 fallback

风险 评估 对策/fallback
weapi RSA raw 最易踩坑 ✅ 已交叉校验正确 保持 mbedtls_rsa_public()+左补零到 256 hex;用 rsa_raw_test 做回归
deviceId/UA 不稳定触发风控 高 deviceId(52 hex) 首生成后持久化;UA 按 eapi(iPhone)/weapi(Chrome) 分别固定;轮询≥1s、失败退避
session(MUSIC_U)丢失即需重扫码 高 cookie 持久化;启动用 /weapi/w/nuser/account/get 校验
eapi 的 header/e_r 缺失 → 4xx 中 demo 已带,必须保留(§8)
无版权/VIP 曲目 url=null 中 song_url_v1 返回 null → 提示 + 降级到 song/enhance/player/url(br=320000) 兜底
音质策略(Hi-res 降级) v1 只保 mp3 账号非免费档 level 被钳到免费档;v1 默认 standard(128k)/higher(192k);不引入 flac 解码(14MB 内存+单核不划算);后续再评估 dr_flac
触发高频风控 中 请求限频、重试退避、必要时随机国内 IP(X-Real-IP/X-Forwarded-For)
mbedtls flash ~388KB 偏大 中 见 §4 裁剪;若预算吃紧切 BearSSL(≈96KB)

7. 出入清单(只改 patch_v2/ncm_app/)

patch_v2/ncm_app/
├── vendor/                        # 各库源码(上游 clone/tar + 许可证)
│   ├── mbedtls/     (3.6.2, 含 framework 子模块, 裁剪 config 在 config/mbedtls_config_ncm.h)
│   ├── bearssl/     (官方 git, 无语义版本)
│   ├── jsmn/ cjson/ qrcodegen/ minimp3/ dr_mp3/
├── libs_common.sh                 # 公共 zig/ar/size 环境
├── build_mbedtls.sh               # 裁剪 config + 静态编 mipsel → out/mbedtls/mipsel/*.a
├── build_bearssl.sh               # make lib → out/bearssl/mipsel/bm/libbearssl.a
├── build_cjson.sh build_jsmn.sh build_qrcodegen.sh build_minimp3.sh build_dr_mp3.sh
├── build_demo.sh                  # 宿主 + mipsel 的 HTTPS demo 复现
├── bench/                         # minimp3/dr_mp3 基准(sample-15s.mp3 来自 samplelib)
├── demo/https_unikey.c            # eapi + TLS1.2 + jsmn(打 unikey)
├── demo/rsa_raw_test.c            # weapi RSA raw 校验
├── out/https_unikey_mipsel(. _s)  # mipsel 静态 demo(qemu-mipsel 跑通)
├── out/mbedtls/{mipsel,host}/     # 库产物     out/bearssl/mipsel/bm/
├── VENDOR_NOTES.md                # 版本/许可/裁剪/体积
└── REPORT.md                      # 本报告

未做/待主会话:真机 hw:0,0 播放链路、扫码后真实登录(803+set-cookie)、weapi 真实登录态请求、 CA bundle 内置方案、音质档位映射实测。这些属设备/账号侧联调,由主会话在设备上执行(本预研不动设备)。

8. 结论

  • 可行:纯 C + mbedtls(裁剪) + jsmn + qrcodegen + minimp3,静态 mipsel 交叉编译全通,HTTPS POST 拿到真实 unikey,MIPS(qemu) 版全链路跑通。
  • 推荐 TLS/加密一体用 mbedtls(单库一次解决 TLS + AES/MD5/RSA raw/Base64 + X509,规避 RNSS-raw 手写风险);BearSSL 为省 flash 备选。
  • v1 体积增量 ≈ 438 KB(库)/ 只做 mp3,内存估计数百 KB 工作集,14MB 可行;主要优化点是 mbedtls 裁剪与封面限幅。
  • 最关键风险(RSA raw)已验证;其余风险集中在风控(deviceId/UA 稳定持久化)与无版权降级策略。

9. 核心库批次(core/,纯 C 静态库,无 UI)

日期:2026-08-31。在 §1-8 预研(候选库/工具链已定型)基础上落地核心库:http/crypto/login/api/cache 五模块

  • 公共/存储/JSON/请求构建支撑模块 + ncm_ctx 上下文。仅改动 patch_v2/ncm_app/。未 commit/push/刷机/动设备。

9.1 模块与公开 API(头文件签名)

模块 文件 主要公开函数
公共工具 core/ncm_common.h/.c ncm_hex_encode/decode ncm_b64_encode/decode ncm_md5_hex ncm_sha256_hex ncm_url_encode/decode ncm_random_bytes ncm_now_ms 等
JSON core/ncm_json.h/.c ncm_json_parse ncm_json_obj_get ncm_json_arr_get/len ncm_json_path[_str/_int/_bool] ncm_json_get_str_at/get_int_at
HTTP core/ncm_http.h/.c ncm_http_request(一次性 POST/GET,限幅)ncm_http_download(流式回调 + max_bytes)ncm_http_parse_set_cookie ncm_http_set_verify_default
存储 core/ncm_store.h/.c ncm_store_init ncm_store_get/set_device_id ncm_store_machine_key ncm_store_cookie_encrypt/decrypt ncm_store_save/load/clear_session ncm_store_write/read_file ncm_store_read_cfg_int
加密 core/ncm_crypto.h/.c ncm_crypto_eapi ncm_crypto_weapi ncm_crypto_linuxapi ncm_crypto_rsa_raw_selfcheck
请求构建 core/ncm_req.h/.c ncm_req_build(eapi/weapi/linux,注入 header/csrf_token/UA/Cookie)ncm_req_cookie_get
登录 core/ncm_login.h/.c ncm_login_qr_url ncm_login_unikey ncm_login_qr_matrix(qrcodegen)ncm_login_poll ncm_login_status_check ncm_login_save/load/clear_cookie
歌单/播放 core/ncm_api.h/.c ncm_api_user_playlist ncm_api_playlist_detail ncm_api_song_detail ncm_api_song_url ncm_api_song_url_auto ncm_api_lyric ncm_api_level_str
缓存 core/ncm_cache.h/.c ncm_cache_init ncm_cache_set_limit_gb/limit_gb ncm_cache_load_setting ncm_cache_lookup ncm_cache_open/write/commit/abort ncm_cache_total_bytes ncm_cache_evict ncm_cache_path
上下文 core/ncm.h + ncm_ctx.c ncm_ctx_t(deviceId/cookie/CA/默认音质/cache)ncm_ctx_init ncm_ctx_set/load/clear_cookie ncm_ctx_default_cache_dir

关键常量:NCM_EAPI_KEY/NCM_WEAPI_PRESET/NCM_WEAPI_IV/NCM_LINUXAPI_KEY/NCM_RSA_DER_B64(ncm_crypto.h); UA 按加密类型分流(eapi=iPhone NeteaseMusic 9.0.90/5038,weapi=Chrome/Edg 124),请求头注入对齐 CNMPlayer request.rs(deviceId/UA 稳定)。

9.2 关键实现取舍

  • CA 方案(ncm_http.h 注释):verify_ca=1(默认)加载系统 CA bundle(或 ca_path),失败即拒绝(防 MITM);verify_ca=0 跳过校验(仅调试/受限网)。全局默认由 ncm_http_set_verify_default 设定,ncm_ctx_init 以其 verify_ca/ca_path 设置。设备侧需内置 CA 根(VENDOR_NOTES §备注),否则接口 eapi/weapi 会被拒。
  • RSA raw:用 mbedtls_rsa_public() 无 padding 纯模幂,16 字节 secret 右对齐 128 字节(前导 0),输出 256 小写 hex——已与 Python pow(m,e,n) 逐字节一致(见 9.4)。
  • weapi:双层 AES-128-CBC(params base64,含 +/= 需 urlencode)+ RSA(raw) 加密随机 16 位 base62 密钥(RSA 前反转)。
  • 会话cookie:加密落盘 MeowData/<pkg>/data/session.enc,密钥 = sha256(MAC + 盐);无 MAC(qemu/无网卡)回退到设备私有 .mkey 派生。仅为防裸读模糊,非真安全(头文件已注明)。
  • 缓存:目录默认 MeowData/os.meow.ncm/cache/;上限默认 10GB(1~100 可配),读设置键 ncm.cache_limit_gb(<data_dir>/ncm.cache_limit_gb.cfg,对齐 SDK meow_config);LRU 按文件 mtime,ncm_cache_lookup/commit 刷新;命中即离线可播。
  • 音质:ncm_api_song_url_auto 从 desired(默认 Hi-res)往下降级 hires→lossless→exhigh→higher→standard,只返回可解码 mp3(url 非空且 type=mp3);无该档对当前歌曲降级。

9.3 构建

./build_core.sh → out/core-host/、core-mipsel/(自有代码对象 + vendor qrcodegen + jsmn 唯一实现 TU),链接 out/mbedtls/{host,mipsel}/*.a,产出:

  • test/test_ncm_core_host(宿主,可跑)
  • out/test_ncm_core_mipsel(mipsel 静态)。size:text 462662 / data 212 / bss 27688(含测试 printf 基建 + mbedtls + qrcodegen;核心库本身链接更小)。
  • 自有代码 -Wall -Wextra 0 warning;vendor 警告豁免。mipsel 用 .tools/zig-mipsel-cc 静态交叉。

9.4 验证记录(宿主 + qemu-mipsel 双向实测,响应已脱敏/截断)

  • unikey 实拿(eapi):{"code":200,"unikey":"<uuid>"}(宿主 dc5bd090-…,qemu-mipsel ef9a5db2-…);二维码 URL https://music.163.com/login?codekey=<uuid>。
  • 轮询(未扫码):{"code":801,"message":"等待扫码"} → state=等待扫码(801),两平台一致。
  • login_status(伪造 cookie):返回 weapi 可解析 JSON(code 200 但 profile null)→ 库判 NCM_ERR_AUTH(-13),证明 weapi 请求被服务端接受。
  • RSA raw 交叉:ncm_crypto_rsa_raw_selfcheck 输出 55b33e7e…b2b83,与 Python pow(m,65537,n)(§3 已交叉验证值)逐字节一致。weapi params 为合法 base64(RSA 封装的 16 字节随机密钥无法无私钥解密,其 AES-CBC 为标准用法)。
  • eapi 往返:ncm_crypto_eapi 产物可用 AES-ECB 解回含 url + json 的明文(/api/login/qrcode/unikey + {"type":3})。
  • cookie 加解密:38 字节串 MUSIC_U=…; __csrf=xyz; os=pc 加密→解密完全一致(含一次 mbedtls crypt_cbc 就地改写 IV 的坑,已修)。
  • 缓存:写入/命中/abort 不留档/强制小上限触发 LRU 淘汰(总字节降至 ≤ 上限)。
  • 二维码:41×41 内矩阵生成,暗模块 >0。
  • 无凭据错误处理:user_playlist(uid=0,无cookie) 优雅返回(公开数据或错误码,不崩溃);song_url(id=0) 返回无 url。

9.5 md5(自有源码)

778847a8419325af2b95ad0ef7749781  core/ncm_api.h      c07d246c9b64e4d3b9078b9e33d19fe4  core/ncm_cache.h
6e878566f6a4c4377c98e87e9967fdcc  core/ncm_common.h   9056253bfb635d8224e7454c6b1796a2  core/ncm_crypto.h
157807f07a96f43249f6aeb9707d053f  core/ncm.h          477dd9f5eab61e2940cb432d1b215b3a  core/ncm_http.h
398e62a0b641f13bc80c02d5e42a0401  core/ncm_json.h     05fb336d3b65b041095bd8fb3c64bc3c  core/ncm_login.h
96f423ef1a36bda7b5cb3961fbe22028  core/ncm_req.h      02c7ff38dd856455f32a4cc7bbc5edd5  core/ncm_store.h
f8f8e96464d3c4ebcf1898eb4212c55a  core/jsmn_impl.c    a00e9b9b1adf7c6cf924eb9ecfc90a67  core/ncm_common.c
e75c29217ef5dd3f0e23c740b79210c1  core/ncm_crypto.c   9ebfa83e31a530b4dc8fd7877c2f5fb9  core/ncm_ctx.c
319df449ecd7727f328e01adb518ba57  core/ncm_http.c     d69ff302ec111a74f621571818da4622  core/ncm_json.c
a53e55f9cab828fd59b694330ec7371e  core/ncm_login.c    d81eda09640ff748b0a1c14607447d1b  core/ncm_req.c
1ceda66d30d44a3d8850df2e876c0195  core/ncm_store.c    7cdb84fcf87384eb5125e5aae01eac84  test/test_ncm_core.c
94a4dd07fba98a6e7b70021aaeb8cb19  build_core.sh

产物:test/test_ncm_core_host md5 0e41d3d413819768d65d2e9ca9e082ed;out/test_ncm_core_mipsel md5 e05f1937e5fc49a2590b830c28ee6372。

9.6 出入清单(只改 patch_v2/ncm_app/)

新增:
  core/ncm.h ncm_ctx.c                // 伞头 + 上下文
  core/ncm_common.h/.c                 // hex/base64/md5/sha256/urlencode/errcode
  core/ncm_json.h/.c                   // jsmn 路径导航
  core/ncm_crypto.h/.c                 // eapi/weapi/linuxapi + RSA raw
  core/ncm_http.h/.c                   // mbedtls HTTPS POST/GET + CA取舍 + 超时 + chunked
  core/ncm_store.h/.c                  // deviceId/MAC机器密钥/cookie加解密/配置读取
  core/ncm_req.h/.c                    // 加密请求构建(md5/UA/cookie 注入)
  core/ncm_login.h/.c                  // unikey/二维码矩阵/轮询状态机/cookie捕获/login_status
  core/ncm_api.h/.c                    // 歌单/详情/歌曲/播放地址/歌词
  core/ncm_cache.h/.c                  // LRU 缓存 + 离线可播
  core/jsmn_impl.c                     // jsmn 唯一实现 TU(防多 TU 重复定义)
  test/test_ncm_core.c                 // 宿主 + mipsel 综合实测
  build_core.sh                        // 宿主 + mipsel 编译链接
修改:
  REPORT.md                            // 追加本批记录
(未动 vendor/,未动其余目录)

9.7 未做/诚实出入

  • 803 真登录未实测(需真实扫码):code 803 分支的 set-cookie 捕获/合并逻辑按 document 实现,但未真跑;联调时建议真扫码验证一次 MUSIC_U 内容与 login_status 校验链路。
  • weapi params 无法无 RSA 私钥解密核验其内部 AES 双层,但 RSA 封路径已逐字节交叉,AES-CBC 为标准用法(可用 CNMPlayer 同源实现比对)。
  • 播放地址/缓存仅验证到“请求-解析-无版权返回 null”;未做真实音频下载落盘(缓存 open/write/commit 已由单元测试覆盖;真实 mp3 下载留给带 UI 的播放层用 ncm_http_download+ncm_cache_* 串联)。
  • 栈用量:单次 http 调用涉及 mbedtls ssl/净栈,峰值栈估几十 KB(与预研 demo 同级);App 线程栈需 ≥64KB,联调时以真机线程栈为准。
  • JSON 响应限幅:歌单/歌曲详情走 1MB~2MB 堆缓冲 + token 计数后精确分配,不算常驻;封面/大 JSON 不整体驻留,封面下载另有 max_bytes 限幅(建议 ~256KB,未在本次接线)。

10. 网易云客户端 v1 = App + UI + 播放链路(os.meow.ncm)

日期:2026-08-31。在 §9 核心库之上落地 App:UI(登录/歌单/播放/设置)+ 播放链路(URL 降级→缓存→minimp3→audio_out)+ 媒体会话 IPC。 仅改动 patch_v2/ncm_app/app/;未动 sdk/、meow_launcherd/(并行域冻结);未碰 vendor/、core/(§9 核心库原样使用)。 宿主 + qemu-mipsel 双边实测见 §10.5。未 commit/push/刷机/动设备。

10.1 包结构与页面

patch_v2/ncm_app/
├── app.json                 # {"name":"网易云音乐",exec:.../os.meow.ncm/ncm,package,version:0.1,version_code:1,framework:1,immersive:1,fonts:gbk}
├── build_ncm.sh             # mipsel 静态 0 warning
├── build_ncm_host.sh        # 宿主离屏自测(MEOW_UI_DUMP)
├── ncm                      # mipsel 静态 App(md5 见 §10.6)
├── app/app.c                # main + 装配 + 事件分发(物理键不映射;左缘左滑=返回/退出)
├── app/ncm_app_internal.h   # 内部共享头(ACT_*/布局/数据上限/播放运行态/各页与 play/ipc 接口)
├── app/ui/
│   ├── chrome.c             # TopAppBar/内容区几何/空态/页脚/底部提示条(ncm_snack)
│   ├── login.c              # 登录页:后台 worker: unikey→二维码矩阵→轮询→803 落 cookie→login_status
│   ├── home.c               # 我的歌单(meow_listview:folder图标+名称+曲目数副标题+箭头)
│   ├── playlist.c           # 歌单详情(曲目列表:歌名+歌手;点击起播并推进播页)
│   ├── player.c             # 播放页(封面+标题/歌手+进度条seek+播放/暂停/上下曲+滚动歌词;竖/横布局可切)
│   └── settings.c           # 设置(音质 Hi-res/较高/标准 + 缓存上限 1~100GB + 退出登录)
├── app/logic/
│   ├── appstate.c           # ctx/cookie/uid/歌单/歌曲/设置加载与持久化(meow_config_*)
│   ├── lyric.c              # LRC 时间轴解析(原文+翻译合并、排序、行定位;流式有界 NCM_LYRIC_LINES)
│   └── cover.c              # 封面后台线程下载(≤288KB)+解码单张缓存 + 落盘 cover_current.jpg 供 launcher
├── app/play/
│   ├── player_manager.c     # 播放线程:URL 降级→缓存(命中即播/否则流式落盘)→minimp3→audio_out;暂停冻结/seek重开/上下曲
│   ├── audio_out.c          # 音频输出:ALSA 直开 hw:0,0(内核UAPI) / FILE(WAV) / NULL(测RT)
│   ├── ipc_media.c          # 媒体会话 IPC:MSG_META 上报 + MSG_PLAYCTL 接收(meow_ipc.h 直接帧格式)
│   └── minimp3_impl.c       # minimp3_ex 唯一实现 TU
└── app/test/
    ├── test_ncm_app.c       # 宿主真实页面渲染(NCM_PAGE=login|loginlive|home|playlist|player|lands|settings)
    └── test_decode.c        # 解码链路(minimp3→audio_out FILE)验证

页面/导航:根页 = 已登录 ? ACT_HOME : ACT_LOGIN。栈:HOME → PLAYLIST → PLAYER;SETTINGS 任意页可进。 左缘左滑(on_swipe LEFT):PLAYER→pop 回 PLAYLIST;SETTINGS→pop;PLAYLIST→pop 回 HOME;HOME/LOGIN→meow_ui_request_exit() 退出 App。

10.2 关键实现

登录(ui/login.c):进入即起后台 worker(网络全在独立线程,UI 不卡)。ncm_login_unikey(eapi) → ncm_login_qr_url + ncm_login_qr_matrix(qrcodegen) → 渲染 37×37 位图(白底+黑模块 6px,居中)→ 每 1.5s ncm_login_poll(801/802/803/800)。803:捕获 set-cookie 合并 → ncm_ctx_set_cookie 加密落盘 → ncm_login_status_check 取 uid/nickname → 置 done → UI tick meow_view_page_replace(ACT_HOME)。800(过期)自动刷新二维码;网络失败 tap 重试。

主页(ui/home.c):meow_listview 虚拟化(folder 图标 + 名字 + N tracks 副标题 + 右侧箭头);空态/会话过期/错误绘制物;topbar 标题=昵称 + 「↻」刷新;行数据轻量(不下载封面缩略图)。点击行 → playlist_open(idx)。

歌单详情(ui/playlist.c):ncm_api_playlist_detail(trackIds) + 分批 ncm_api_song_detail(≤256 首,NCM_SONG_MAX);行=歌名+歌手;点击行 → player_play_song(row)(起播 + 推播放页)。

播放页(ui/player.c):封面(cover_request 后台解码,meow_ui_img_cover 居中裁方 220px)+ 题/歌手 + 进度条(手绘,点 bar seek)+ 控制钮(prev/play-pause/next)+ 滚动歌词(lyric_line_at(pos) 居中高亮)。横竖屏可切:device LCD 为竖屏 480×720,故为布局切换(竖=封面上/歌词下;横=封面左/信息歌词右),topbar 「⟳」触发,player_set_screen()。进度/状态/歌词每 tick 读 player_manager_*(跨线程 mu)。

设置(ui/settings.c):3 行卡片(音质/缓存上限/退出登录)。音质→BottomSheet(Hi-res/Higher/Standard);缓存→BottomSheet(1/2/5/10/20/30/50/100 GB);退出→confirm 弹窗 → appstate_logout() + replace ACT_LOGIN。持久化 MeowData/os.meow.ncm/data/ 下 ncm.level/ncm.cache_limit_gb(键与核心库 ncm_cache_load_setting 对齐)。

10.3 播放链路(app/play/player_manager.c)

player_manager_play(idx) → 播放线程外层循环 → run_song(idx):
  ① song_local_file:  ncm_cache_lookup 命中 → 直接播(离线);否则
     ncm_api_song_url_auto(desired=设置音质, 逐档降级 hires→…→standard, 只收 mp3)
       + ncm_http_download 流式写缓存 temp → ncm_cache_commit(先落盘再播)
  ② mp3dec_ex_open(path, MP3D_SEEK_TO_BYTE) → audio_out_open(44.1k/16/2)
  ③ 循环 mp3dec_ex_read → audio_out_write(pcm, frames) → frames_done→pos_ms(~200ms 节流更新)
     暂停=冻结(停读停写停推 pos,状态 PAUSED);seek=重开解码器+audio_out 并字节 offset 近似定位;
     上下曲/停止=打断 run_song 回外层;播完=自动顺延下一首(无则 STOPPED)
  • 缓存:目录 MeowData/os.meow.ncm/cache/(ncm_cache_*),LRU 上限可配(默认 10GB,1~100)。
  • audio_out:宿主/qemu 无 /dev/snd/pcmC0D0p → 回退 FILE WAV(NCM_AUDIO_SINK=file / NCM_AUDIO_OUT=<path>)或 NULL(NCM_AUDIO_SINK=null,仅测 RT)。设备走 ALSA 内核 UAPI 直开(sound/asound.h,masks/intervals 重索引已按 musl 头修正),真机时序待验证。

10.4 媒体会话 IPC(app/play/ipc_media.c,SDK 无媒体封装自写最小客户端)

  • 连接 MEOW_IPC_SOCK(缺省 AF_UNIX /usr/data/meow_ipc.sock)。
  • MSG_HELLO(app_id=os.meow.ncm, flags=MF_PLAYBACK) → 读 ACK。
  • 同 fd 每 ~1s 发 MSG_META(m_meta_t:title/artist/album/pos_ms/dur_ms/state=_1播 2暂停 0停 3缓冲,flags=MF_ART|MF_COVER(有 cover_current.jpg 时),artwork=该文件路径,app_id)。launcher 以最近活跃会话为准 → 通知中心媒体卡片 + Now Playing 页。
  • poll(fd,POLLIN,200ms) 收 MSG_PLAYCTL → cmd 1播放/2暂停/3上一首/4下一首/5seek → player_manager_*。
  • 无 launcher socket(宿主/qemu 无守护)→ log ipc: no launcher socket 并优雅返回。

10.5 验证记录(宿主 + qemu-mipsel 双向实测)

项 方式 结果
各页 UI 宿主离屏 MEOW_UI_DUMP + PIL 转 PNG 目检 login/home/playlist/player/lands/settings 全部正确(截图 app/out/shots/*.png)
登录真实二维码 NCM_PAGE=loginlive:核心库真实 ncm_login_unikey→qr_matrix→渲染 真 unikey 40287b2e-9ef8-4f2e-8bf5-04febdaf631a→37×37 可扫 QR(out/shots/loginlive.png)
mipsel 二进制 qemu-mipsel ./ncm(离屏+MEOW_UI_DUMP,timeout SIGTERM 干净退出) 真实 mipsel 静态二进制跑通:start→ipc: no launcher socket(优雅)→登录页渲染真实可扫 QR(out/shots/mipsel_qemu.png)
解码链路 test_decode(minimp3→audio_out FILE WAV) MIPSTest.mp3 → 8.21s/48k/立体声/16bit PCM,WAV 合法(ch=2 rate=48000 frames=393984),446× RT(解码余量巨大)
构建 build_ncm.sh mipsel 0 warning(自有代码 -Wall -Wextra);text 764KB

宿主截图:app/out/shots/{login,loginlive,home,playlist,player,lands,settings,mipsel_qemu}.png(PPM→PNG)。 注:宿主 5×7 点阵字仅 ASCII(中文/歌名在设备上以 gbk 大字库渲染;桌面截图为 ASCII label 布局自查)。

10.6 md5

1e331964b62b6f5b0a593c5d97a4e030  ncm                      # mipsel 静态 App
(app 源码 md5)
50dc58dc62a9d71a23e90bb00c79a36b  app/app.c                cf5e3283065dfce798d668c22ee87f65  app/ncm_app_internal.h
b961bc769951208a09017f278c05a95b  app/ui/login.c           5098bd843f6943bcda5aacfb7fae961f  app/ui/home.c
ef600c9d3a88eb2463d1c8f312a5a202  app/ui/playlist.c        a55846822deb0246c6780d8a7ac99f46  app/ui/player.c
05fef4b6fa5692abf0da57db40ad6f90  app/ui/settings.c        013111dea3172fdeb5353a2ec69a71a5  app/ui/chrome.c
203a9932bde8a739b436e246ba634638  app/logic/appstate.c     48a92829ac414ffad5dbe27e0836c21b  app/logic/lyric.c
ed2d9658bc8707a5fd689b386da472f9  app/logic/cover.c        e7cef918d2be04f6d4c5a25f62d2a9ab  app/play/player_manager.c
4ceeb2f4f1b0a312c9468b9f77886dc7  app/play/audio_out.c     8c1716ca99cd773a0cf9a105f793b845  app/play/ipc_media.c
6ca4b2137c4a424d823f94a8b3fcd037  app/play/minimp3_impl.c  a2ca48b2c6afa6c36f045ba8b2687fc5  app/test/test_ncm_app.c
9ebfb5401c7818cfd0c21d640b07c521  app/test/test_decode.c

10.7 出入清单

新增(仅 patch_v2/ncm_app/):
  app.json                          # 应用描述
  build_ncm.sh / build_ncm_host.sh  # mipsel / 宿主构建
  app/app.c ncm_app_internal.h
  app/ui/{chrome,login,home,playlist,player,settings}.c
  app/logic/{appstate,lyric,cover}.c
  app/play/{player_manager,audio_out,ipc_media,minimp3_impl}.c
  app/test/{test_ncm_app,test_decode}.c
  out/shots/*.png                   # 验证截图
修改:
  REPORT.md                         # 追加本批记录
(未动 sdk/、meow_launcherd/、vendor/、core/;核心库 §9 原样使用)

10.8 真机待验证项

  1. ALSA 直开 hw:0,0:本层用内核 UAPI(/dev/snd/pcmC0D0p + ioctl hw_params:RW_Interleaved/S16_LE/44100/2ch)——设备时序/权限/采样率匹配待真机验证;宿主已回退 FILE sink 证解码链路。
  2. 真实扫码登录(803):set-cookie 捕获→加密落盘→login_status 校验链路按文档实现但未真扫;建议真机扫码验证一次 MUSIC_U 内容与 home 拉歌单闭环。
  3. 媒体会话:需 launcher 在真机跑(本 App 首真实媒体源);验证通知中心媒体卡片/Now Playing 显示 MSG_META + 下发 MSG_PLAYCTL 生效(本 App 已记 ipc: no launcher socket 为优雅降级,真机有 socket 即上报)。
  4. 大字库:app.json fonts:gbk 声明已加;真机验证歌名/歌词中文渲染(宿主 ASCII 5×7 不含中文)。

10.9 诚实出入 / 已知限制

  • 封面为歌单封面(playlist_detail 的 coverImgUrl)而非逐歌曲专辑封面:ncm_song_t 未含 al.picUrl,为不改 §9 核心库(已驯化契约)故 v1 用歌单封面代为 artwork;逐曲封面需给 ncm_song_t 增 al.picUrl 字段(附加、向后兼容,建议二期)。
  • 歌词为同步加载:播放页 enter 时 player_manager_load_lyric 同步网络拉取(UI 短暂阻塞);建议后续改后台线程+就绪标志(音频线程独立不受影响)。
  • 歌单曲目截断:NCM_SONG_MAX=256(大歌单显示前 256 首);歌单数 NCM_PLAYLIST_MAX=120。
  • launcher 媒体卡片封面:经 MSG_META artwork 传 <cache>/cover_current.jpg 路径;若 launcher 读不到该路径则卡片仅题/艺/进度(App 内封面始终有)。
  • IPC 仅处理 MSG_META/MSG_PLAYCTL(未接 MSG_SYS,媒体会话最小闭环,符合 v1)。
  • bss≈924KB / text≈764KB:mipsel 静态;14MB 内存富余,但 bss 含封面下载缓冲(288KB)+QR 矩阵(31KB)+mbedtls 静态,已留充足余量。
  • meow_snack/页脚为 App 自绘(非 SDK 全局通知,避免与 launcher 系统 UI 冲突)。

10.10 真机 weapi 挂死根因定位 + 修复(2026-08-31,已真机闭环)

现象:真机 music.163.com/weapi/...(login_status_check/user_playlist)请求永久卡死, 线程 wchan=poll_schedule_timeout,>10 分钟不超时;同机的 eapi(interface.music.163.com)正常。 定为 多个独立缺陷叠加,其中「超时用墙钟算」是那把导致「>10 分钟不超时」的关键一把。

根因(按“致卡死”权重排序)

  1. 超时/截止用 CLOCK_REALTIME(墙钟,非单调)—— 这是「>10 分钟不超时」的真根因。 ncm_http.c 的 wait_io() 与 deadline 都用 ncm_now_ms()(CLOCK_REALTIME)。设备在请求进行中被 NTP 把墙钟拨回了 Δ(校时 step,方向可正可负)。于是 rem = (uint32_t)(deadline - now) = (now0 + tmo) - (now0 - Δ) = tmo + Δ;若 Δ 为小时级/天数级, rem 变成巨大的 uint32_t(甚至溢出回绕),mbedtls_net_poll() 因此拿到远大于 15s 的等待窗口, 线程长期阻塞在 poll_schedule_timeout。墙钟拨回越大,阻塞越久——这正解释了“>10 分钟且与 eapi 行为无关”(eapi 恰好在该时刻没撞上拨回,或撞上后被验证时序放行)。 宿主演示(bench/monotonic_test.c):拨回 6 小时。

    • 墙钟 rem = 21615000 ms ≈ 6.0 h(poll 阻塞 6 小时)
    • 单调钟 rem = 15000 ms = 15 s(不变)

    结论:超时/截止计算必须用 CLOCK_MONOTONIC(永不跳变)。墙钟只留给 request_id/_ntes_nnid/ 落盘时间戳这类“取当前墙钟”的场合。

  2. 响应头解析“隔行跳行”,漏掉 Content-Length。 parse_headers() 旧循环 while((p=strstr(p,"\r\n"))){ p+=2; ...; p=eol+2; } 每轮把下一行吞掉: 处理 Server 后直接跳到 Connection,中间 Date/Content-Length/Vary… 被跳过。 导致 content_length 停留在 SIZE_MAX → body_st_init 落入 BS_CLOSE(读到对端关闭)。 若服务器 keep-alive + 无 chunked(网易常见),body 将一直读到对端关;配合根因①的墙钟异常, 阻塞被拉长成“永久卡死”。宿主复现:/-11 FULL(见根因③)前先看到 status=0 content_length=0。 修法:逐行遍历(先定位状态行 \r\n,再逐 header 行到空行),并顺带修正状态码解析(旧代码取 "1.1 200 OK" 的首空后即报 200,实际取到 1.1 → status 恒 0)。

  3. 请求头缓冲 1024 太窄 → 真机大 cookie 时 NCM_ERR_FULL,请求根本没发出。 weapi 带真实会话 cookie 时 extra_hdrs(UA + Content-Type + Cookie + Referer)可达 ~1.1KB, 旧 char headbuf[1024] 溢出,http_run 返回 -11 FULL(真机实测 dev_repro2:rc=-11, status=0 content_length=0 headers_len=0,即请求未发)。修法:headbuf[8192](≥ extra_hdrs + ~200)。

  4. weapi 发送路径把长 cookie 值截断(MUSIC_U 只剩 191 字节)→ 服务端识别为游客(profile:null)。 ncm_req.c cookie map 的 ck_t.val[192] 会把 834 字节的 MUSIC_U 截到 191 → 服务端看成 guest 会话, login_status 返回 {"code":200,"account":null,"profile":null}(-13 AUTH)。修法:cookie map 改存 值指针 + 长度((const char*)val, size_t vlen,不复制不截断),并按名去重;生成值(_ntes_nnid/ WNMCID/NMTID)buffer 提升到函数作用域保证生命周期。cookie_full 4KB。

  5. 响应头/体切分顺序 bug(我修 split 时引入后已修正):在 split 处 NUL 终结头部会覆盖 body 首字节, 导致 body 首字节变 NUL → JSON 解析失败(-9)。已改为先送 body 再 NUL 终结头部(二者同址)。

  6. Content-Length: 0 没立即结束:补 body_st_init 对 content_length==0 直接置 BS_DONE,避免等对端关。

修法(全部在 patch_v2/ncm_app/)

  • core/ncm_common.h/.c:新增 ncm_monotonic_ms()(CLOCK_MONOTONIC);ncm_now_ms 注明仅用于墙钟。
  • core/ncm_http.c:
    • wait_io + deadline 改用 ncm_monotonic_ms();
    • 重写 parse_headers(逐行、状态码用 strtol 忽略 reason、去掉 16KB 栈缓冲改 128B);
    • headbuf[1024]->[8192];split 处先送 body 再 NUL 终结;Content-Length:0 直接 BS_DONE。
  • core/ncm_req.c:cookie map 改 (ptr,len) 存储(不截断、按名去重);生成 cookie 值 buffer 提作用域; cookie_full[2048]->[4096]。
  • core/ncm_login.c:login_status_check 设 timeout_ms=6000(硬上限)且 respbuf[16384](容纳真实 profile)。
  • app/ui/login.c:803 后即时 status_check 取 uid/nickname 并 appstate_set_logged_in 落盘 (修复“803 后 uid 恒空”;worker 线程执行,不卡 UI)。
  • app/logic/appstate.c:appstate_load_playlists 记 home: playlists rc=? count=? uid=?(供真机日志核对; appstate_ensure_uid 因 uid 已落盘而短路,不再阻塞歌单页;status_check 有 6s 硬上限兜底)。

验证记录(宿主 + 真机)

宿主(./test/test_ncm_core_host,全 PASS;另 benchmark 复现工具)

  • bench/monotonic_test.c:墙钟拨回 6h → rem=21615000ms;单调钟 → rem=15000ms。根因①钉死。
  • bench/host_repro.c(weapi account/get,空 cookie):修复后 rc=0 status=200 content_length=42 body_len=42, body 正确为 {"code":200,"account":null,"profile":null}(修复前 status=0 content_length=0 body=0)。
  • bench/host_fullcookie.c + 真机解密 cookie:全量 cookie(未截断、去重)→ 服务端返回 body_len=1161(真实 profile); 被截断/带旧重复 token 的 cookie → 服务端返回 body_len=42(guest)。根因④钉死:去重+全量才被认可。
  • 宿主测试套件:cache/qrcodegen/network(unikey/poll)/no-credential 各用例全 PASS(无回归)。

真机(最终确认 1 次,风控:真实 cookie 请求严格受限)

  • bench/verify_home.c(复刻 App home 链路:status_check→user_playlist,链接修复后 core):
    status_check rc=0 elapsed_ms=721 uid=408996274 nick=Any无言
    user_playlist rc=0 elapsed_ms=2562 count=64
      [0] Any无言喜欢的音乐 (5191 tracks)
      [1] Docu (1 tracks) ... [7] Ethereal (2 tracks)
    VERIFY OK: user=408996274 playlists=64
    
    即 home 真实列出用户 64 个歌单,满足验收。
  • 真机中途定位记录(修复前):dev_repro2 见 rc=-11(全量时 status=0/content_length=0)(根因③);dev_repro 见 cookie_len=1842(重复 cookie,根因④由 host_fullcookie 证明需去重+全量);修复后 dev_repro 见 status_check rc=0 uid=408996274 nick=Any无言(根因②③④⑤全消)。
  • 部署:/usr/data/mnt/sd_0/MeowAPPs/os.meow.ncm/ncm(先 ncm.new 再 mv),md5 bf6d2a79504d49cdc9b87e6f34cc6aec。

出入 / 已知限制(本批)

  • mipsel -Wall -Wextra 自有代码 0 warning(build_ncm.sh 复核);vendor 豁免。
  • 未动 sdk/、meow_launcherd/、vendor/,未 commit/push/刷机;未动设备系统/恢复区。
  • 历史遗留:真机 session.enc 里是旧版重复 cookie(MUSIC_R_T/A_T 等重复多次);本次通过发送路径 “按名去重 + 全量不截断”规避(verify_home 已证明服务端认可)。未追改 merge_cookie/落盘去重 (发送端已去重,功能不受影响);如需落盘也干净,可后续在 ncm_login_poll 的 merge_cookie 处按名去重。
  • appstate_ensure_uid 仍同步跑在 UI 线程,首次(uid 未落盘时)会占 UI 至多 ~6s 硬上限(正常 ~0.7s); 真正的“不阻塞歌单页”可由「uid 落盘后短路」+「6s 硬上限」保证,未改为后台线程(避免范围扩散)。

10.11 真机三问题修复:启动卡顿 + 大歌单 Load Failed + 小歌单 No tracks(2026-08-31,已真机闭环)

现象(用户真机实测):登录通、歌单列表 64 个。三个问题: ① 有登录态点开 App 卡数秒才进;②「我喜欢的音乐」(5191 首) 点开 Load Failed;③ 其余歌单全部 No tracks。 本批定位多处独立缺陷并修复,全部经宿主复现 + 真机验证。仅改动 patch_v2/ncm_app/。

根因(按现象对应)

  • P1 启动卡数秒:app/ui/home.c home_page_enter → home_reload → appstate_load_playlists(weapi user/playlist)在 UI 线程同步执行;真机该 weapi 往返 ~2.5s+(verify_home: elapsed_ms=2562),uid 未落盘时还叠 status_check(6s 硬上限)。期间 UI 冻死(paint_full 不执行)。
  • P2 大歌单 Load Failed:core/ncm_api.c:ncm_api_playlist_detail 发 {"id":..,"n":100000,"s":8} + 响应 cap NCM_RESP_MAX(1MB)。5191 曲歌单 detail 真机实测 status=200 cl=3168819(3.0MB:playlist.tracks 塞满歌曲对象),1MB cap 溢出 → NCM_ERR_FULL(-11) → Load Failed。关键:trackIds 数组与 n 无关(n=1 时仍完整返回 5191 条,实测),是 tracks(完整歌曲对象)撑大响应。
  • P3 小歌单 No tracks:ncm_api_song_detail 把曲目 id 数组塞进 {"c":"[...]"} 时内部引号未转义,实发 {"c":"[{"id":2048962657}]"}(坏 JSON),服务端回 200 Content-Length:0 → jsmn 空体 → NCM_ERR_JSON(-9) → 0 首。宿主复刻:转义后 {"c":"[{\"id\":2048962657}]"} → 服务端回 2146B 正常 songs。
  • P0(连带新发现):网易云歌曲 id 可 >2^31(如 2746301825),而 App 用 long 存 song_ids;MIPS32 的 long=32 位,strtol 溢出饱和到 LONG_MAX(2147483647) → 大歌单 trackIds 全错(2746301825 被解析成 2147483647),song_detail 拿错误 id 只回 30 首。宿主 aarch64(long=64 位)不暴露,真机 MIPS32 才炸。
  • P3b(连带):批量 song_detail 转义后 json 变长 → eapi params[2048] 溢出 → ncm_crypto_eapi 返 NCM_ERR_CRYPTO(-8)(batch=64 实测 rc=-8)。

修法(全部在 patch_v2/ncm_app/)

  • P1 app/ui/home.c:加载改后台 worker(home_worker,detached);home_reload 立即返回并画 LOADING;home_tick 按 g_home_gen 完成代数回填。app_on_tick 路由 home_tick。ensure_uid(status_check)+ user_playlist 全在 worker,UI 不阻塞。(顺带消掉 §10.10 遗留的「ensure_uid 同步跑 UI 线程」。)
  • P2 core/ncm_api.c:ncm_api_playlist_detail:n 100000→1(tracks 只带 1 首,3MB→~900KB);响应 cap 1MB→2MB(trackIds 大的歌单留余量)。
  • P3 core/ncm_api.c:ncm_api_song_detail:c 数组内部引号转义({\\\"id\\\":%lld})。
  • P0 core/ncm_common.{h,c} 新增 ncm_parse_i64(strtoll);core/ncm_json.{h,c} 新增 ncm_json_get_i64_at;core/ncm_api.h 两处 id 签名 long*→int64_t*;app/ncm_app_internal.h song_ids[] long→int64_t。
  • P3b core/ncm_req.c eapi 局部缓冲 full2[3072]→[4096]、params[2048]→[4096]。
  • 防大歌单冻 UI:app/ui/playlist.c 歌单详情同样后台化(playlist_worker + playlist_tick);appstate_load_playlist_songs 不再覆写 g_st.active_playlist(防后台 worker 覆盖用户已切换的歌单)。

验证记录

宿主(同 core 库 + 真实服务器)

  • bench/host_chain_probe(detail→song_detail 背靠背,小歌单 18206253175):修前 song_detail rc=-9;修后 rc=0 bn=1 song_count=1。
  • bench/host_big(大歌单 581857874):修前 big detail 溢出;修后 detail rc=0 idn=256 + 4×64 批全 rc=0 → song_count=256,首曲 Chrono Rewind / CVRCH。
  • 宿主 eapi 复刻:未转义 c → 服务端 200 CL:0;转义后 200 len=2146。P3 钉死。
  • test/test_ncm_core_host 全 PASS;build_ncm.sh mipsel -Wall -Wextra 0 warning。

真机(bench/verify_fixes,一次运行,小歌单 2 请求 + 大歌单 5 请求)

device_id=BD6DE5CC2DBCB617253A00438897DC6C02FFC7CFDB99DB0FA08E cookie_len=1842
[small(Docu/1)] detail rc=0 idn=1 ids[0]=2048962657
  song off=0 batch=1 rc=0 bn=1            RESULT song_count=1
  [0] Verve (feat. FJØRA) / Ryan Taubert
[big(5191)]    detail rc=0 idn=256 ids[0]=2746301825     ← 修 P0 后正确(修前=2147483647)
  song off=0/64/128/192 batch=64 全 rc=0 bn=64
  RESULT song_count=256  [0] Chrono Rewind (时空回溯) / CVRCH

→ P2/P3/P0/P3b 真机全消:小歌单 1 首、大歌单 256 首(不再 Load Failed / No tracks)。

真机启动(部署后后台运行 5s)

6213257 [os.meow.ncm] INFO start
6217019 [os.meow.ncm] INFO home: playlists rc=0 count=120 uid=408996274   ← 后台 worker 完成
6218319 [os.meow.ncm] INFO exit

→ 启动即进 home;home: playlists 由 worker 与 UI 并发产出,UI 不再被 weapi 阻塞(P1)。修前旧二进制日志 start→home playlists 同链路为 UI 线程同步(home.c 阻塞)。

MIPS32 溢出佐证(P0):修 P0 前真机 verify_fixes:big detail idn=256 ids[0]=2147483647(strtol 溢出饱和);修后 ids[0]=2746301825。

部署 + 出入清单(本批,只改 patch_v2/ncm_app/)

  • 部署:/usr/data/mnt/sd_0/MeowAPPs/os.meow.ncm/ncm(先 ncm.new 再 mv),新 md5 f6bd765a374effaf866784c3165e476e(旧 bf6d2a79504d49cdc9b87e6f34cc6aec)。
修改:
  core/ncm_api.c           # P2 n=1+2MB cap;P3 c 转义;P0 int64 签名
  core/ncm_api.h           # int64_t ids 签名
  core/ncm_req.c           # P3b eapi 局部缓冲提升
  core/ncm_common.h/.c     # 新增 ncm_parse_i64
  core/ncm_json.h/.c       # 新增 ncm_json_get_i64_at
  app/app.c                # app_on_tick 路由 home_tick/playlist_tick
  app/ncm_app_internal.h   # song_ids→int64_t;声明 home_tick/playlist_tick
  app/ui/home.c            # P1 home 加载后台化
  app/ui/playlist.c        # 歌单详情后台化(防大歌单冻 UI)
  app/logic/appstate.c     # load_playlist_songs 不覆写 active_playlist
新增:
  bench/verify_fixes.c     # 真机复验小歌单+大歌单链路(回归工具)
  REPORT.md                # 本批记录
(未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机;未动设备系统/恢复区)

变更文件 md5:

ceb89ee9e83752208056e2eadb5330fd  core/ncm_api.c   63d4af4307c5cdb3922e59dc70116407  core/ncm_api.h
cc1ab202c8d603e296f02715718a8a25  core/ncm_req.c   45efe7071b8cbff552ac89fe8b2e9495  core/ncm_common.c
8fc658f656a1004f80ab5bc1555c2048  core/ncm_common.h df46e0cd3215d359025519b4d41316d6  core/ncm_json.c
6d473afe13176230268c68ddc655cb34  core/ncm_json.h  3d2daea41204c887915d8c078543afe0  app/app.c
282c24806fe0e9c97adf464f6a5c2776  app/ncm_app_internal.h  10c3b4b1097f1b0d82550659d0a87c4f  app/ui/home.c
0efef753b32eef144f2c30e4592fbfea  app/ui/playlist.c  94d10237c1aa73f7273f82ff319792f5  app/logic/appstate.c
bcfd75e5eb872a751c166c456eea0184  bench/verify_fixes.c  f6bd765a374effaf866784c3165e476e  ncm

诚实出入 / 已知限制

  • 大歌单 load 慢(真机 detail ≈30s):/v6/playlist/detail 无论 n/s 都返回全量 trackIds(5191 个 ≈900KB,实测 n=1/0/100000 均 5191 条),无法用该端点分页/限流。真机 MIPS32 下载 + jsmn 解析 ≈30s(宿主 4.7s,~7× 慢,符合 MIPS vs aarch64 预期)。已用「playlist 后台化」避免冻 UI(显示 LOADING),但大歌单首屏仍 30s 级等待;要根治需改走 /api/playlist/track/all(服务端分页)或流式 jsmn——建议二期。
  • n 只限 tracks 不影响 trackIds:n=1 对 ≤256 曲歌单仍完整返回全部 trackIds(idn=实际曲数,无回归);仅把「多出的完整歌曲对象」从响应里去掉,从而把 3MB 压回 ~900KB。
  • 曲目截断仍 NCM_SONG_MAX=256(现协议):大歌单显示前 256 首,未做滚动加载(二期)。
  • g_st.songs[]/song_count 由 playlist worker 写、play/IPC 线程读(§10.9 FM4 旧已知风险):入播放页仍在同歌单,竞态窗口极小;沿用未加锁(与项目现状一致)。
  • 真机真实请求计数:本批新增 ~20 次(detail_probe 抓样 1 次(5 请求,样本落盘)+ song_probe 1 + app_path_probe 2 + verify_fixes 2 次 11 + 启动验证 2)。已尽量「一次请求存样本、离线重放」,但修复前后复验需真实响应,属必要开销。
  • 未动 sdk/、meow_launcherd/、vendor/,未 commit/push/刷机,未动设备系统/恢复区。

11. 网易云 UI 重做(按 NCM_UI_DESIGN.md,2026-08-31)—— 双套配色 / 黑胶播放页 / 质感

依据 devdocs/plan/NCM_UI_DESIGN.md 将 App 从「文件管理器列表风」重做为网易云风格: 白红/黑红双套配色、主页双页签「我创建的/我收藏的」、独立搜索页、歌单详情封面头块+「播放全部」、 播放页黑胶唱盘(fill_rect 行扫构造圆 + 方盖内切裁圆封面 + 唱针)。仅改动 patch_v2/ncm_app/。 未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机。

11.1 双套配色机制(§3.1/§3.3)

  • 新增 app/ui/ncm_colors.c:ncm_pal_t{ncm_palette(dark)} 两套 App 调色板(白红 / 黑红 + 播放页/登录专属色)+ g_dark 全局 + ncm_on_theme(mode) 主题侦听。
    • 品牌红走 App 侧(SDK 全局 primary 是 MD3 蓝,App 无权改——红只落 App 自绘层)。
    • 中性色(surface/onSurface/onSurfaceVariant/outlineVariant/error)一律 meow_theme_get(); 全程语义色 + App 调色板,零硬编码散落(§3.1 契约)。
  • app/app.c:meow_theme_add_listener(ncm_on_theme)(旧代码传 NULL 无效)+ 启动同步 g_dark = (meow_theme_get_mode()==MEOW_THEME_DARK)。系统明暗切换 → ncm_on_theme → 整 App 重绘实时换肤。
  • 色值(§3.2):day red #C20C0C / red_lit #EC4141 / red_ctr #FBE9E9; night red #EC4141 / red_lit #FF6A6A / red_ctr #3A1919;登录 hero night #7A0E0E; 播放页底 day #FFFDF9 / night #0C0D10;盘体 day #1A1A1A / night #141414。 验证(宿主抽样像素):day 组头红条 (198,12,8)≈#C20C0C,night (239,65,66)≈#EC4141。✅

11.2 数据层(§7.0 明示缺口)

  • core/ncm_api.h:ncm_playlist_t 增 int is_collected;(1=收藏的/订阅他人,0=自己创建)。
  • core/ncm_api.c:ncm_api_user_playlist 解析响应 subscribed(obj_bool)写入 is_collected, 支撑「我创建的/我收藏的」分组(原实现只取 id/name/coverImgUrl/trackCount)。
  • core/ncm_api.h/.c:新增 ncm_api_cloudsearch(weapi /api/cloudsearch/pc,type=1 单曲), 解析 result.songs[](id 用 int64/name/ar[0]/al/dt),关键词 " \ 转义防坏 JSON。

11.3 页面改动

  • 登录页(ui/login.c):品牌红 hero 带(y=0 起,沉浸式)+ 红点 logo + 「网易云音乐/扫码登录」 标题区 + QR 卡(圆角 16 白底/surfaceContainer + 内描边)+ 状态字(day red/night red_lit)+ 底部提示。
  • 主页(ui/home.c):红系 topbar(标题「我的音乐」+ 刷新/设置/搜索动作,搜索按钮最右)+ 个人区卡片(首字圆形头像 red_ctr + 昵称 + ID + 箭头)+ 双页签「我创建的/我收藏的」 (meow_tabbar,红下划线/红选中字叠层注入)+ 组头红条(red_ctr 底 + 红字 + 红侧条)+ 歌单列表(rounded card 行 + 红 mini-vinyl 缩略 + 红色按压竖条);列表数据源按 is_collected 过滤分组。
    • MEOW_LIST_SEL_NONE + 自定义红态叠层(§4.2:SDK listview MD3 蓝选中不可改,改叠红条)。
    • 保留 home_worker 后台异步加载(§10.11 P1 修复),未回退成同步。
  • 搜索页(ui/search.c 新增,ACT_SEARCH):TextField(SDK IME,仅英文/数字)+ 红色「搜索」确认钮 + 结果列表(点击可播,复用 g_st.songs[] 作为当前播放上下文)。风控纪律:搜索只在用户点「搜索」钮时发 (进入页面不自动搜索、不逐键发请求);网络走后台 worker(search_worker)。
  • 歌单详情(ui/playlist.c):封面头块(圆角封面/占位 + 歌单名 + 「创建者 · N 首」)+ 操作条 (红色「播放全部」 FILLED + 「收藏」OUTLINED(v1 置灰→snack) + 「评论」红字+红badge(v1 置灰→snack))+ 曲目列表(歌名 + 歌手·专辑 副标题);保留了 §10.11 的后台 playlist_worker。
  • 播放页(ui/player.c):黑胶唱盘(fill_rect 行扫构造圆盘 + 环槽减淡 + 方盖内切圆 + 四角把「圆外-方外」盖回盘色 → 封面成圆,§5.1 技巧)+ 唱针(3px 唱臂 + 端头, 播放压盘/暂停微抬 6px)+ 高光弧 + 进度条(已播红)+ prev/play/next 控制钮(play 红)+ 滚动歌词 (当前行红高亮居中,裁剪到歌词带)+ 竖/横布局可切(沿用 player_set_screen)。
    • 恒沉浸:播放页底 day 瓷白 #FFFDF9 / night 近黑 #0C0D10,不随系统明暗整体反转;accent (进度红/play 钮/歌词高亮)日夜换红明暗(day #C20C0C / night #EC4141,§5.4)。
  • 设置页(ui/settings.c):4 行卡片(音质/缓存上限/退出登录/关于)+ 分组说明;退出登录用 error 色; 音质/缓存 → BottomSheet;退出 → confirm;关于 → snack。行圆角 12,语义色。
  • chrome.c:ncm_draw_empty kind 大字改品牌红;页脚 dim。

11.4 性能(§6 专项)

  • 播放页分带刷新:内容拆「盘带 g_disc_r」+「动态带 g_dyn_r」两个绘制物,bbox 互不重叠。 player_tick 只标脏变化的那一条带——进度/歌词/时间只刷动态带,盘带仅 歌曲/封面/播停态 变化才重画 (修原 player_tick 每 tick 刷整 r_cont 的隐患,§6.1/§6.2)。
  • 黑胶圆盘仅 封面/状态变化 时重画;歌词行裁剪到歌词带(自定义绘制物不按 bbox 裁剪,防溢出叠控制钮)。
  • 封面限幅沿用(NCM_COVER_MAX=288KB,单张);搜索结果上限 NCM_SEARCH_MAX=32(无大缓冲,§6.7)。

11.5 验证记录(宿主 + mipsel)

  • 宿主离屏 MEOW_UI_DUMP 双套配色各截一轮:7 页 × 2 主题 = 14 张 app/out/shots/{login,home,playlist,player,lands,settings,search}_{dark,light}.png(PPM→PNG)。
    • 红色抽样:day #C20C0C(组头红条)、night #EC4141 均正确;播放页恒沉浸(瓷白/近黑)✅。
    • 黑胶盘/唱针/封面裁圆/进度/控制/歌词居中 目检正确 ✅;主页双页签、个人卡、组头红条、红缩略 ✅。
  • test/test_ncm_core_host 全 PASS(无回归);out/test_ncm_core_mipsel(qemu)全 PASS。
  • mipsel build_ncm.sh 自有代码 -Wall -Wextra 0 warning;ncm 静态,text 803084 / data 824 / bss 928356。
  • qemu-mipsel 冒烟 timeout 10 qemu-mipsel ./ncm:start→ipc: no launcher socket(优雅)→登录页 unikey 重试(rc=-5 无 CA,预期)→exit 干净退出,无崩溃。
  • 未做(诚实):真机搜索/歌单请求仅留最少验证或不打(风控约束);未真机扫码/播放出声(用户联调项)。
  • 真机部署未执行:本机无可达设备主机(meowos.local 不解析;ssh 无设备别名/IP,WORK_STATE 亦标注 热点组播不通)。二进制已就绪(md5 90a25cf0c193fd965d6685fb099bacb7),部署命令留用户执行: scp ncm root@<device-ip>:/usr/data/mnt/sd_0/MeowAPPs/os.meow.ncm/ncm.new && ssh root@<device-ip> 'mv /usr/data/mnt/sd_0/MeowAPPs/os.meow.ncm/ncm.new /usr/data/mnt/sd_0/MeowAPPs/os.meow.ncm/ncm'(先 .new 再 mv)。

11.6 md5(自有源码 + 二进制)

90a25cf0c193fd965d6685fb099bacb7  ncm                       # mipsel 静态 App(新)
c2921983e878ce0cefe1f279f6d16d3f  app/app.c                8585677b732420ead899c34d07823a05  app/ncm_app_internal.h
359ad93833d3ef1914c97da28d249d09  app/ui/login.c           970e4ec732036ae8d8de137c960863c4  app/ui/home.c
ac9e1585e96b1a1a336cbbc9a82a8c86  app/ui/playlist.c        3023fb024ea096e458b52fb4255db709  app/ui/player.c
c4f6cd8569d6ddc4dd15d4111752ed77  app/ui/settings.c        ba78835b3fbe6166bbe39f2e9ea9041a  app/ui/search.c
54f10365ed640130ee33fba2e674b547  app/ui/ncm_colors.c      aa93822cd735f3912556df6a301a3580  app/ui/chrome.c
94d10237c1aa73f7273f82ff319792f5  app/logic/appstate.c    539252a2ea6a6a675f94356898176b99  core/ncm_api.h
1c1c05b2fada19c40e8a83a2742afbd7  core/ncm_api.c          30ab44e6f6cbc8b07eb477e7b8ee82a3  build_ncm.sh
71ed280698b67cba76a90d19a43f8458  build_ncm_host.sh       2a9663abe58609fafcc22949b8e8771c  app/test/test_ncm_app.c

注:app/logic/appstate.c 本批未改(md5 未变 94d10237...);build_ncm_host.sh 因补链 SDK meow_ui_nav/meow_ui_media/meow_notify_ipc 而更新(宿主 SDK 源后来新增这些文件,原脚本缺链导致 meow_notify_ipc_notif_open 未定义;属构建脚本适配,非 SDK 改动)。

11.7 出入清单(只改 patch_v2/ncm_app/)

新增:
  app/ui/ncm_colors.c             # ncm_palette/g_dark/ncm_on_theme/ncm_fill_round/ncm_draw_button/first_glyph/ncm_text_fit
  app/ui/search.c                 # 搜索页(IME + cloudsearch + 结果列表)
修改:
  app/ncm_app_internal.h          # ncm_pal_t/g_dark/ACT_SEARCH/搜索状态/新页声明/调色板助手声明
  app/app.c                       # 主题侦听 + search 页建/路由
  app/ui/{login,home,playlist,player,settings,chrome}.c  # 重做/重着色
  core/ncm_api.h/.c               # is_collected(§7.0) + ncm_api_cloudsearch
  app/test/test_ncm_app.c         # is_collected mock + NCM_THEME 双主题 + search mock
  build_ncm.sh / build_ncm_host.sh# 源列表加 search/ncm_colors;host 补链 SDK nav/media/notify_ipc
(核心库 §9 其余、vendor/、sdk/、meow_launcherd/ 未动;未 commit/push/刷机)

12. 二期四件功能批:专辑封面 / 歌词后台加载 / 大歌单分页 / UI 遗留修复(2026-08-31)

依据任务批(REPORT §9-11 之上)做四项:① ncm_song_t 加 al.picUrl,播放页黑胶封面用真实专辑图 (沿用 cover.c 缓存/限幅);② 播放页歌词改后台 worker + 就绪标志,enter 不再同步阻塞拉词; ③ 大歌单 playlist/detail 全量 trackIds 太大(5191 首 ≈900KB,§10.11 已记录)→ 改走 /api/playlist/track/all(eapi 服务端分页),曲目列表滚动加载(50 首/页);④ 修 UI 遗留: 歌词字面 \n 未转换 + 播放页底部提示豆腐块(字库缺字)。 仅改动 patch_v2/ncm_app/;未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机/动设备。

12.1 专辑封面(ncm_song_t.al_pic + 播放页用真实专辑图)

  • core/ncm_api.h:ncm_song_t 增 char al_pic[512](al.picUrl,追加字段向后兼容)。
  • core/ncm_api.c:抽出 parse_songs_array() 统一解析歌曲对象数组(id/name/ar[0].name/al.name/al.picUrl/dt/fee, id 数字/字符串均以文本拷贝,兼容 >2^31)。ncm_api_song_detail、ncm_api_cloudsearch 改共用该函数 → 歌单/搜索两路都带上专辑封面。
  • app/ui/player.c player_play_song:黑胶封面 URL 改为 s->al_pic[0] ? s->al_pic : g_st.cover_url(无 al_pic 回落歌单封面); player_tick 在 sc != g_last_song(切歌/自动顺延)时对新曲 cover_request() 换封面。
  • 封面缓存/限幅沿用 app/logic/cover.c(≤NCM_COVER_MAX 288KB、单张、后台线程下载解码 + cover_current.jpg 供 launcher),未改。

12.2 歌词后台加载(player_manager.c + player.c)

  • 新增持久歌词 worker 线程(lyric_worker,detached):每 300ms 读 g_play.cur_song,曲目 id 变化即 lyric_clear() → ncm_api_lyric → lyric_parse()(网络+解析全在后台);状态 g_lyr_state(0 空闲/1 加载中/2 已拉完)。
  • player_manager_load_lyric() 改为非阻塞触发:只 lyric_ensure_worker() 启动 worker 即返回(UI 不再同步阻塞)。 换曲/自动顺延由 worker 轮询自捕获,无需逐处触发。
  • 新增 int lyric_state(void);player.c plr_draw_dyn 歌词空态改为:lyric_state()==2 ? "no lyric" : "loading lyric..." (消除“无词”与“加载中”歧义)。
  • 竞态(§10.9 FM4 已接受)沿用:UI 读 lyric 模块与 worker 写为良性(数组定长、越界已防护,最坏一帧旧/空行自愈)。

12.3 大歌单分页(/api/playlist/track/all 服务端分页 + 触底加载)

  • core/ncm_api.h/.c:新增 ncm_api_playlist_track_all(device_id, cookie, pid, offset, limit, songs, max, &n, &total) → eapi POST /api/playlist/track/all,body {"id","offset","limit","n"=limit,"s":8},直返该页歌曲对象(含 al.picUrl, 免去 detail 瓶颈 + 二次 song_detail)。数组路径防御 songs→playlist.tracks→data.songs;total 取 total→count→playlist.trackCount→musicCount。 另导出 ncm_api_parse_track_all(json,len,...) 纯解析版(离线样本重放/单测,不打网络)。
  • app/logic/appstate.c:新增 appstate_load_playlist_songs_page(idx, offset, limit)(offset==0 重头清空、>0 追加到 g_st.songs[offset..],封顶 NCM_SONG_MAX);appstate_load_playlist_songs(idx) 保留为兼容整批(内部按页循环)。
  • app/ui/playlist.c:首屏 playlist_refresh() 拉 offset=0 一页(NCM_PLAYLIST_PAGE=50);playlist_tick 按 g_ploffset==0 决定「回顶/保滚动」;新增 pl_maybe_load_more()(触底且 g_pl_more 且未到 NCM_SONG_MAX 时加载下一页), 在 playlist_touch_move/playlist_swipe 滚动后触发;追加失败仅 ncm_snack("加载失败") 并保留已载歌曲(不整页清空)。
  • app/ncm_app_internal.h:NCM_PLAYLIST_PAGE 50;声明分页函数 + lyric_state。

12.4 UI 遗留修复

  • 歌词字面 \n(app/logic/lyric.c):lrc_parse 复制正文改经 copy_lrc_text()——把字面 \n/\r (服务器把多行文本以转义折进一行)转空格,\r\n/\n\r 折叠为单空格;有界 ≤LRC_MAX_TEXT。
  • 播放页底部提示豆腐块(app/ui/player.c):页脚 "左右滑切歌 · 点顶部切横屏" 的 ·(U+00B7) 不在 common 字库 → 豆腐块。root cause:common tier 按 GB2312 解码 A1A4 得 ・(U+30FB)(gb2312 无 U+00B7,gbk 才有),故 common 图集只有 U+30FB。 已替换为 ・(U+30FB)(在 common 图集内,meow_font_class_of→OTHER→路由主槽 slot0 即命中)。其余页面的 ·(U+00B7) 为同一 bug(settings/login/playlist/search 也有),为避免范围扩散 + 保设计原貌,本期只改播放页页脚(用户实测报点); 已在「诚实出入」标注。

12.5 验证记录(宿主为主;风控纪律:新增真实 NCM 请求 = 0 次)

项 方式 结果
track_all 离线解析 bench/offline_track_all.c(合成样本,纯解析,0 网络) 全 PASS:songs 命中、id>2^31 不溢出(2746301825)、al.picUrl 正确(song0/song1)、total=5191;变体 playlist.tracks+trackCount 容错命中
歌词字面 \n 宿主编译 app/logic/lyric.c + 定向输入(\\n/\\r\\n) PASS:"hello\nworld"→"hello world"、"second\r\nline\nhere"→"second line here"
App 三页 宿主离屏 MEOW_UI_DUMP(mock,0 网络) player/playlist/home 渲染正常(黑胶/进度/控制/歌词、分页 footer 6/128、红行),无崩溃
歌词后台状态位 lyric_state() 语义 + mock 下 g_host_mock 短路(worker 未启、count>0 → 显示歌词) 正常
mipsel 构建 bash build_ncm.sh(自有代码 -Wall -Wextra) 0 warning(仅脚本 echo);text 812856 / data 840 / bss 1059556
qemu-mipsel 冒烟 未跑(避免触发真实 unikey 请求;启动路径未改,§11.5 已验) 未做

注:out/test_ncm_core_mipsel 已由 build_core.sh 重建(含新增 test_track_all_parse),但未运行(其 test_network 会打真实 NCM 请求);track_all 解析已由 bench/offline_track_all(0 网络)覆盖。

12.6 md5(自有源码 + 二进制)

9c4a79f669967d193cba9fdaa41f8442  ncm                       # mipsel 静态 App(新)
9d1ff1ad5027c1932c7f4f0163df8b92  core/ncm_api.h          6b2daef7a48215fcd7369351e09ee25f  core/ncm_api.c
8067ef74af7db49340b693ec62c654f0  app/ncm_app_internal.h  7e54b48012fdfb392ba5910d22153078  app/logic/appstate.c
c3a4aa5cd3da493d5cdb75965aaaa4fe  app/ui/playlist.c       610a223232d250d2e0413031bb8613ae  app/play/player_manager.c
9bf68c8420bd8c819cf318b5a16d3d9f  app/ui/player.c         612f6e92cade6a59d33f3b5d6aee7b13  app/logic/lyric.c
58ee655751a8efb69df850dc96afdcc1  test/test_ncm_core.c    61b019c64a805e8873db21482def3408  bench/offline_track_all.c

12.7 出入清单(只改 patch_v2/ncm_app/)

修改:
  core/ncm_api.h .c                 # ncm_song_t.al_pic + parse_songs_array(解析 al.picUrl)
                                    # + ncm_api_playlist_track_all(分页) + ncm_api_parse_track_all(离线解析)
  app/ncm_app_internal.h            # NCM_PLAYLIST_PAGE + appstate_load_playlist_songs_page + lyric_state
  app/logic/appstate.c              # 分页加载 appstate_load_playlist_songs_page(兼容整批保留)
  app/ui/playlist.c                 # 触底加载更多(50/页,cap 256);追加失败保留已载
  app/play/player_manager.c         # 歌词持久 worker + 异步 load_lyric + lyric_state
  app/ui/player.c                   # 黑胶封面用 al_pic;异步歌词空态(loading/no lyric);页脚 ·→・
  app/logic/lyric.c                 # LRC 字面 \n 转空格(copy_lrc_text)
  test/test_ncm_core.c              # 新增 test_track_all_parse 离线断言
新增:
  bench/offline_track_all.c         # track_all 离线样本重放(0 网络)
  REPORT.md                         # 本批记录(§12)
(未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机/动设备)

12.8 诚实出入 / 已知限制

  • 未真机验证:本机无可达设备主机(与 §11.5 同:meowos.local 不解析、无设备别名/IP),故未真机跑。 二进制已就绪(md5 9c4a79...),真机部署 + 验收(1-2 次请求)留用户执行: scp ncm root@<device-ip>:/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm.new && ssh root@<device-ip> 'mv ...ncm.new ...ncm'(先 .new 再 mv)。
  • /api/playlist/track/all 端点形态未 live 验证(无宿主 cookie,且 NCM 真实 + 风控纪律不允许多请求): 请求/解析按 ncm-api-rs 生成的 eapi 路由 /playlist/track/all + API.md 的 limit/offset 文档语义实现, 并对数组路径(songs→playlist.tracks→data.songs)与 total 做了多路径容错;若真实响应字段有出入, 解析器会据实际样本在下一轮对齐(离线单测已覆盖本实现所依据的形态)。
  • http 封面 URL 旧疾:core/ncm_http.c 对 http:// 也走 TLS 握手(当前实现只判 scheme 再统一 TLS), 若个别 al.picUrl/coverImgUrl 为纯 http 会握手失败。这是 v1 同源遗留(v1 已用 coverImgUrl), 本期未改 core(避免动已验证的 TLS 层);实测 NCM 专辑/歌单封面多为 https,可后续统一跳转 https 或加 http 明文分支。
  • 页脚等 ·(U+00B7) 残留:settings/login/playlist/search 的 · 同为 common 缺字 → 会豆腐块(用户仅报播放页, 本期修播放页);如需全面修,把全 App 的 U+00B7 换成 U+30FB 即可(同一 bug,非功能缺失)。
  • topbar 图标字符潜在同病:home/player topbar 动作标签 ⟳(U+21BB)/⚙(U+2699)/⌕(U+2315) 走 meow_font_class_of→OTHER 类,若 common 图集无对应字形同样会豆腐块;用户未报(可能在 topbar 用了符号字库槽或未留意), 本期未动(避免改非报点区域),后续如需一并确认/替换(如改用 ASCII 或 in-atlas 字符)。
  • song_ids[] 字段曾在 §12 被闲置(track_all 直返歌曲对象);§13 已回退 detail+song_detail,重新用于 暂存 playlist/detail 返回的 trackIds 以驱动 song_detail 分批(§13 为此更新)。
  • 歌词竞态:UI 读 lyric 与 worker 写保留良性竞态(§10.9 FM4 同接受);真机如需彻底消除可后续给 lyric 模块加锁。
  • 构建方差异:宿主 build_ncm_host.sh 对 cover.c 有 glibc-fortify -Wformat-truncation 告警(改动前即存在, 仅宿主 glibc 触发;mipsel musl 无此告警,build_ncm.sh 0 warning)。

13. 真机两修:歌单详情 Load Failed 根因(track/all 端点不存在)+ 歌单列表误触(2026-08-31,已真机闭环)

现象(用户真机验收):① 登录/主页 64 歌单正常,但进任意歌单都 Load Failed——§12.3 新换的 /api/playlist/track/all 分页端点从未真机验过,真机响应字段存疑(§12.8 已自标风险); ② 偶发误触:歌单列表点到非触摸坐标的行(列表触摸坐标有偏差/串行)。 本批先真机用最小请求量抓 track/all 真实响应样本、对齐结论,再修复并用真机验证。仅改 patch_v2/ncm_app/, 未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机;未动设备系统/恢复区。

13.1 根因①:/api/playlist/track/all 端点根本不存在(404「接口未找到」)

按 §12.8 的“真机抓真实响应样本对齐”计划:设备侧链路(bench/probe_track_all.c,用与 App 相同的 ncm_req_build+ncm_http_request)对小歌单 18206253175(Docu/1 首)抓原始响应体。为把“端点是否存在/响应字段” 与“解析对错”解耦,探针绕过解析器直发请求,先打原始 body。三次(风控 ≤3)真实请求结果:

路由 加密 URL 原始响应
/api/playlist/track/all eapi https://interface.music.163.com/eapi/playlist/track/all {"code":404,"message":"接口未找到!"}
/api/v6/playlist/track/all weapi https://music.163.com/weapi/v6/playlist/track/all {"code":404,"message":"接口未找到!"}
/api/playlist/track/all weapi https://music.163.com/weapi/playlist/track/all {"code":404,"message":"接口未找到!"}

→ NCM 服务端没有 track/all 这条路由(eapi/weapi、v6/无v6 全 404)。三次请求 body 均为 {"code":404,"message":"接口未找到!"}(HTTP 200 但业务 code=404),故 api_post 判 NCM_ERR_HTTP(-10) → playlist_worker 置 g_load_err → 任意歌单都 Load Failed。 §12.3 把歌单详情从“验证过的 /v6/playlist/detail + song_detail(§10.11)”换成基于一条不存在的路由做“服务端分页”, 是回归根因;ncm_api_parse_track_all 的“多路径容错”救不了路由不存在——收到的是 404 业务码,不是歌曲数组。

13.2 根因②:歌单列表误触 = 拖动滚动后 release 被 SDK 判 TAP,命中“释放点+新 scroll”而非按下行

先验证“触摸坐标与显示是否几何不一致”(任务提示的方向)。设备侧 bench/probe_input.c(本地 ioctl,零 NCM 请求):

ABS_MT_POSITION_X  min=0 max=480   ABS_MT_POSITION_Y  min=0 max=720
fb0 xres=480 yres=720 bpp=16

→ 触摸面板与帧缓冲 1:1(无缩放/无固定偏移),故“坐标有偏差”不是面板标定/缩放问题,App 侧命中公式 (w->y + w->scroll 与绘同式)也一致——命中数学本身是对的。

真正根因是手势时序:SDK 抬手判 adx/ady < 65px(SWIPE_THRESH)为 TAP;而拖动过程中 touch_move 已 meow_listview_scroll_by(-dy) 滚动列表。于是“小拖动(0..64px)= 列表已滚 + release 仍触 TAP”, playlist_on_tap/home_on_tap 用 meow_listview_hit(释放x, 释放y) = “释放点 + 更新后的 scroll”命中, 打到的是“已经滚动到手指下方”的那一行,与用户按下的行不一致 → “点到非触摸坐标的行”(偶发,手指微动即触发)。 (§12.5 宿主离屏 MEOW_UI_DUMP 只验静态渲染、不发真实触摸,故未暴露。)

13.3 修法

  • Load Failed(app/logic/appstate.c):appstate_load_playlist_songs_page 回退 §10.11 已验证路径—— /api/v6/playlist/detail(n=1,取前 NCM_SONG_MAX 首 trackIds)+ /api/v3/song/detail 分批取歌曲对象 (parse_songs_array 仍带出 al.picUrl,保留 §12.1 专辑封面)。offset==0 重头整批载入(idn≤NCM_SONG_MAX); offset>0(触底“加页”)只要 NCM_OK 无操作——detail 一次已取满至上限,无独立分页。
  • 误触(app/ui/home.c 与 app/ui/playlist.c):点行改用按下行 g_press_row(touch_down 命中行, 与“红条按压”同源);手指移动量 g_press_delta > 10(与按压红条清行同阈值)判为滚动收尾,不触发点行。 干净点击仍播放按下行(更符合“按哪行播哪行”);拖动手势只滚动不再误播。search.c 同款模式, 非本次报点,未改(避免扩范围,见 §13.8)。
  • 仅在 patch_v2/ncm_app/;app//core/ 均未动其它逻辑;ncm_api_playlist_track_all/ncm_api_parse_track_all 保留在 core(现为不可达死代码;offline_track_all/test_track_all_parse 仍可作离线回归)。

13.4 真机验证记录

触摸=fb(probe_input,本地 ioctl):ABS_MT[0,480]×[0,720],fb0 480×720 16bpp → 1:1,排除标定。

修复后数据链路(bench/probe_fix.c,复刻修复后 appstate_load_playlist_songs_page(offset=0) 的 detail→song_detail 完整路径;device_id=BD6DE5…, cookie_len=1842):

=== small (Docu/1, 18206253175) ===
detail rc=0 elapsed_ms=1175 idn=1 ids[0]=2048962657
  song off=0 batch=1 rc=0 elapsed_ms=717 bn=1
RESULT song_count=1
  [0] Verve (feat. FJØRA) / Ryan Taubert / Verve (feat. FJØRA)
FIX VERIFY OK
=== big (我喜欢的音乐, 581857874, 5191) ===
detail rc=0 elapsed_ms=34672 idn=256 ids[0]=36190432
  song off=0/64/128/192 batch=64 rc=0 bn=64 ×4
RESULT song_count=256
  [0] Isla Blanca (Aerosoul Chillout Remix) / Aerosoul / Isla Blanca
FIX VERIFY OK

→ 小歌单 1 首、大歌单 256 首(NCM_SONG_MAX 截断,§10.9 上限)真机都出曲目,不再 Load Failed。 大歌单 detail 真机 ~34.7s(5191 首 trackIds ≈900KB),但由 playlist_worker 后台跑、UI 显示 LOADING 不冻结(§10.11 P1 已修)。

部署二进制冒烟(ncm.new→mv,起 8s 再 SIGTERM):

start → home: playlists rc=0 count=120 uid=408996274 → exit

→ 新二进制启动正常、主页加载 120 歌单、干净退出,无崩溃。

请求量:track/all 诊断恰 3 次(风控 ≤3);修复后验证走 detail+song_detail(验收必需)。 真实请求总计数:诊断 3(track/all)+ 复验 7(small detail/song 2 + big detail/4×song 5)+ 冒烟 1(user/playlist)= ≈11 次, 均为必要开销。另有一次无效运行(data_dir 误传致 TLS 失败),未发服务器请求。

13.5 真机部署

/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm(先 ncm.new 再 mv;ncm.bak2=§12 旧回归二进制, ncm.bak=§10.11 前)。新 md5 734221661ce547393c03ae3568ca3f36(旧 §12 9c4a79f669967d193cba9fdaa41f8442)。

13.6 出入清单(只改 patch_v2/ncm_app/)

修改:
  app/logic/appstate.c   # track_all 404 回归:appstate_load_playlist_songs_page 回退 detail+song_detail
  app/ui/playlist.c      # 误触:点行用按下行 g_press_row + 拖动(g_press_delta>10)抑制
  app/ui/home.c          # 误触(同 playlist.c):点行用按下行 + 拖动抑制
新增:
  bench/probe_track_all.c # track_all 原始响应探针(诊断 404;抓样本用)
  bench/probe_input.c     # 触摸 ABS 范围 + fb0 分辨率探针(本地 ioctl,零 NCM)
  bench/probe_fix.c       # 修复后数据链路真机复验(detail→song_detail)
  REPORT.md               # 本批记录(§13)
(未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机;未动设备系统/恢复区)

13.7 md5(本批改动文件 + 产物)

734221661ce547393c03ae3568ca3f36  ncm                       # mipsel 静态 App(新;已部署设备)
453eeb5d29809854061d8e4e0300855a  app/logic/appstate.c
2ce92315d0e27492b36374f512fabaeb  app/ui/playlist.c
4217763fbf766c77917f86f63abb634c  app/ui/home.c
279ebbcfca65f9225b93b04213f8fde4  bench/probe_track_all.c   7dc88e448fb10a6da24ad48511023307  bench/probe_input.c
5ad3107059bca1f8e14776afdf6abf82  bench/probe_fix.c

13.8 诚实出入 / 已知限制

  • track/all 不存在使 §12.3 的整体翻页目标落空:/api/playlist/track/all(eapi/weapi、v6/无v6)真机全 404, 故“服务端分页 + 第 256 首之后继续加载”无法实现。本期回退为 §10.11 的 detail(全量 trackIds,浏览器首屏 ~34.7s, UI 后台 LOADING 不冻结)+ NCM_SONG_MAX=256 截断。若仍要超过 256 首滚动加载,只能改用 detail 已返回的全量 trackIds 驱动 song_detail 分页(不依赖 track/all),列入后续;本期只恢复到“能出曲目”的可靠基线。
  • ncm_api_playlist_track_all/ncm_api_parse_track_all 现为不可达死代码(服务端无路由),保留以不破坏 offline_track_all.c/test_track_all_parse(离线样本回归,0 网络)。后续可连同 §12.3 一并清理。
  • 误触修复未真机触摸复现:无法经 SSH 驱动真实触摸;修复基于「按下行 + 拖动抑制」的明确逻辑 + 触摸=fb 1:1 的 实测佐证(排除标定)。建议用户真机复验:快速横滑列表后抬手,应只滚动不误播;干净点行仍播按下行。
  • search.c 列表点行同款误触模式(未报点,本期未改,避免扩范围):search_on_tap 仍用 meow_listview_hit(释放点); 如需一并修,套用 §13.3 同名改法即可。
  • 大歌单首屏 ~34.7s:detail 全量 trackIds(≈900KB)在 MIPS32 上解析慢(§10.11 已记录,宿主 4.7s,~7×);后台 worker 避免冻 UI。已恢复到 §10.11 用户接受的基线。
  • al.picUrl 封面保留:song_detail 经 parse_songs_array 解析出 al.picUrl,§12.1 黑胶真专辑封面功能不退。
  • 构建:build_ncm.sh mipsel 自有代码 -Wall -Wextra 0 warning(本批改动文件逐文件复核无告警);vendor 豁免。
  • 未动 sdk/、meow_launcherd/、vendor/,未 commit/push/刷机,未动设备系统/恢复区;部署留用户后续验收。

14. 三期第一批:歌单真分页 + 专辑封面 ncm_cache 化 + 歌词后台加载补锁(2026-08-31,agent-239)

范围:仅 patch_v2/ncm_app/(含 build 脚本与 REPORT 本文件)。未动 sdk/(主会话在改 meow_ui_ime.c)、 meow_launcherd/、vendor/、core/。风控纪律:本批真实 NCM 请求 0 次(全部离线 stub/宿主导出/已落盘样本)。 部署/验收留用户(真机验收点见 §14.7)。

14.1 歌单分页(detail 全量 trackIds + song_detail 分批,取消 256 硬截断,分页驱动)

  • 背景:§13.1 真机确认 /api/playlist/track/all(eapi/weapi、v6/无v6)全 404,§12.3 的“服务端分页”不可行; §13 回退到 detail(n=1)+ song_detail 但 NCM_SONG_MAX=256 硬截断、一次拉满(首屏 34.7s)。本期把 “256 截断 + 一次拉满”改为“detail 全量 trackIds 驱动 + song_detail 分批 + 触底逐页加载”。
  • app/logic/appstate.c appstate_load_playlist_songs_page(idx,offset,limit) 重写:
    • offset==0(重头):detail(n=1) 取全量 trackIds 入 song_ids[](≤ NCM_SONG_MAX),记 track_ids_count; song_count/songs[] 清空;只载第 0 页(song_detail 前 limit 首,其余待触底再拉,不冻 UI、不一次拉满)。
    • offset>0(触底追加):从 song_ids[offset..] 载 song_detail,追加写 songs[offset..], song_count = offset + 本页实际数;失败返回不修改 song_count(保留已载页)。offset>=track_ids_count 或 offset>=NCM_SONG_MAX 时无操作返回。
    • track_ids_count 为“是否还有下一页”唯一判据(song_count < track_ids_count)。
  • app/ui/playlist.c:playlist_worker 按页(NCM_SONG_BATCH)调 appstate_call;g_pl_more = (rc==NCM_OK) && song_count<track_ids_count && song_count<NCM_SONG_MAX;pl_maybe_load_more() 视口触底 → g_ploffset = g_st.song_count 起下一页(append 成功保留滚动位置防跳顶);首屏失败整页空态 + 点按重试; 追加失败 snack + 保留已载歌曲 + 重置 g_pl_more(可再触底/点击重试)。
  • 加载/失败/空态三态:LOADING 正在加载曲目... / LOAD FAILED·SESSION EXPIRED·NETWORK ERROR(点按重试) / NO TRACKS。 点按重试:playlist_on_tap 在 g_load_err!=0 时命中内容区触发 playlist_refresh()。
  • 上限:NCM_SONG_MAX 256→1000(单页渲染上限 = 分页驱动;每首 ncm_song_t ≈1.32KB → 歌曲数组 ~1.37MB, bss +~1MB,14MB 内安全);NCM_SONG_BATCH 64→100(≤500 安全窗内取保守 100;clist[4096] 装 100 首 ≈2KB, 如需 200 首需先扩 clist);删除 NCM_PLAYLIST_PAGE(track/all 遗留,已无引用)。footer 显示 已载/总数(总数取 playlists[..].track_count,如 5191 歌单读到 1000 时显示 1000/5191)。

14.2 专辑封面(黑胶用真实专辑图 + 缓存进 ncm_cache LRU + 修一个真死锁)

  • ncm_song_t.al_pic + parse_songs_array 解析 al.picUrl(§12.1 已落地,本期未改 core);player 黑胶盘封面取自 s->al_pic[0] ? s->al_pic : g_st.cover_url(§12.1),歌单列表/详情头块封面保持现状(cover_get(),未改)。
  • app/logic/cover.c 封面图改走 ncm_cache(LRU 口径沿用 ncm_cache):键 = djb2(封面 URL) → 文件 song_cov<hex>.jpg(与 mp3 song_<id>.mp3 同池,ncm_cache_lookup/commit 刷新 mtime=LRU)。 worker 流程:① 缓存命中直接解码(免下载、二次进入离线可看);② 未命中 → 下载解码 → ncm_cache_open/write/commit 落盘;仍写 cover_current.jpg(save_cover_raw)供 launcher 媒体卡片。
  • 顺带修真 bug:旧 save_cover_raw 在已持 g_cm 的临界区内再 pthread_mutex_lock(&g_cm)(非递归锁自锁死)→ cover worker 永久卡死、g_gen 永不 +1 → 黑胶封面永不刷新。改为“调用方持有 g_cm”(不再二次取锁); 封面写盘缓冲扩到 NCM_CACHE_DIR_MAX+32(顺带消宿主 glibc fortify -Wformat-truncation 告警,mipsel musl 本就无)。

14.3 歌词后台加载(确认已异步;补数据竞争锁)

  • 歌词后台加载 §11(player_manager.c lyric_worker:轮询当前曲 → 异步 ncm_api_lyric → lyric_parse, UI 不阻塞;lyric_state() 判「加载中/无词」)已落地,本批未改后台线程模型(确实不阻塞 UI)。
  • 补 app/logic/lyric.c 数据竞争锁 g_lyric_mu:写侧 lyric_parse 整段持锁(清空用 lyric_clear_nolock 避免二次取锁自锁死);读侧 lyric_count/lyric_line_at/lyric_line_text/lyric_line_tl 每次取锁。 消除 UI 线程(player.c 歌词区)与 IPC 线程(ipc_media.c lyric_count)同 lyric worker 间的数据竞争 (早前为“良性竞态”§10.9 FM4;本期按“禁止数据竞争”要求收紧)。
  • 与现有 g_play.mu 同模式(函数级锁、无嵌套)。已知残余:worker 读 g_st.songs[idx].id 无锁—— songs[] 写读竞态(§10.9 FM4 接受,与播放线程同源),本期未扩范围。

14.4 验证记录(0 真实 NCM 请求)

项 方式 结果
歌单分页状态机 bench/pagination_sim.c 离线 stub(0 网络)驱动真实 appstate_load_playlist_songs_page 全 PASS:130 首读全程(1 detail + 2 song_detail 页,song_count=130/track_ids_count=130,末首含 al_pic);5191 首封顶 NCM_SONG_MAX(1000,10 页,越界无操作);空歌单 0;越过尾部/上限再 load 均无副作用
mipsel 构建 bash build_ncm.sh(自有代码 -Wall -Wextra) 0 warning;text 842820 / data 856 / bss 2052068(bss +~1MB 来自 NCM_SONG_MAX 1000)
宿主离屏渲染 build_ncm_host.sh + MEOW_UI_DUMP(mock,0 网络) home/playlist/player/lands 渲染正常(歌单 6 行 + footer 6/128、黑胶 + 歌词当前行红色高亮),无崩溃
宿主链接 build_ncm_host.sh 补 meow_pinyin.o(主会话 229 拼音 IME 引入 py_predict,旧 host SDK_SRC 漏编 → 链接断);cover.c 截断告警已消

14.5 md5(本批改动文件 + 产物)

b34bc1ed2c3539f1ca80a005884833cd  ncm                       # mipsel 静态 App(新)
1a2327e0ccd352d11bea88ebd35df549  ncm_host                  # 宿主离屏调试二进制
0b0a4dd5e208d7b5eb504befb374a29e  app/logic/appstate.c
6d66eed99c556e301498c5823b43c294  app/logic/cover.c
ace0def0e8c3ec991013b0675957f269  app/logic/lyric.c
1a1871032c2ecae7a1cc80c7aa00381a  app/ncm_app_internal.h
ab87bd5f74b82fd3d62c49a16a4c9fe5  app/ui/playlist.c
c73366373b3a372d96b2218e528c6381  build_ncm_host.sh
141e9098a7cabeade9aa6b442ba20910  bench/pagination_sim.c

14.6 出入清单(只改 patch_v2/ncm_app/)

修改:
  app/logic/appstate.c      # 歌单分页:offset=0 重建 trackIds 索引+载第0页;offset>0 追加;track_ids_count
  app/ui/playlist.c         # 触底逐页加载 + g_pl_more 判据 + 加载/失败/空态 + 点按重试 + 追加失败保留已载
  app/ncm_app_internal.h    # NCM_SONG_MAX 1000 / NCM_SONG_BATCH 100 / 删 NCM_PLAYLIST_PAGE + track_ids_count
  app/logic/cover.c         # 封面走 ncm_cache(LRU) + 修 save_cover_raw 自锁死 + 缓冲扩容消截断告警
  app/logic/lyric.c         # 加 g_lyric_mu(写 parse 整段持锁、读逐次持锁)消除数据竞争
  build_ncm_host.sh         # host SDK_SRC 补 meow_pinyin(拼音 IME py_predict 漏编→链接断)
  ncm / ncm_host            # 重编产物
新增:
  bench/pagination_sim.c    # 歌单分页离线状态机回归(stub 网络端点,0 请求)
  REPORT.md                 # 本批记录(§14)
(未动 sdk/、meow_launcherd/、vendor/、core/;未 commit/push/刷机;未动设备系统/恢复区)

14.7 诚实出入 / 已知限制

  • 真机未部署/未验(风控:0 新增真实请求)。部署链路同 §13.5(scp ncm root@<ip>:/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm.new → ssh root@<ip> '. /etc/profile; mv ...ncm.new ...ncm',先 .new 再 mv 防 ETXTBSY)。真机验收点: ① 小歌单全程可读;大歌单(如 5191)下滑触底逐页加载到 1000 封顶(footer 已载/总数),不冻 UI; ② 黑胶显示真实专辑封面(首次下载→ncm_cache 落盘,二次进入免下载;第三方封面 http:// 若握手失败回落歌单封面, 为 §12.8 既有“http 封面走 TLS”旧疾,本批未改 core 规避);③ 歌词后台加载不卡 UI、切歌刷新、下/上一首顺延。
  • 大歌单仍有内存上限(NCM_SONG_MAX=1000):这是 14MB 硬约束下的取舍(每 +100 首 ≈ +132KB)。 若要全量 5000+ 首,需:a) 歌曲对象瘦身(al_pic[512] 每首占大头,可拆到按需加载),或 b) 列表滑动窗口 (meow_listview 需 O(1) src2(idx) 重映射,复杂度高)。footer「已载/总数」已向用户标明进度。
  • ncm_cache_evict 生产路径未显式触发超限淘汰(§10.9 既有遗留:evict 仅 test 调用;song/cover commit 后不自动 触发)。封面走 ncm_cache 同池后同样受该路径约束——超限淘汰非本批引入,本批也未扩大行为(不主动加 evict 调用, 避免改已验证的缓存写入路径)。
  • 歌词锁残余:读侧函数取锁后返回存量指针(s_txt/s_tl 静态数组),解锁后可能被下一次 lyric_parse 覆盖; 写入仅发生在切歌瞬间(一次/歌),本地读在一两帧内完成,峰值可能显示一帧旧/空行(与 §10.9 FM4 同接受)。 严格消除需调用方持锁拷贝(本批未做,接口需改,避免扩大 diff)。
  • host 构建有 SDK meow_ui_vol.c「system 返回值忽略」告警(SDK 源,非本 App;mipsel musl 无)。本 App 自有代码 0 warning。
  • 分页触底加载与误触/点按为逻辑推断(无法经 SSH 驱动真实触摸/滚动);误触修复(§13.3)未在分页下回归真机。 建议真机快速下滑到底验证逐页加载、点按重试、追加失败保留已载。

14.8 数值权衡(供后续微调)

常量 旧值 新值 说明
NCM_SONG_MAX 256 1000 单页渲染上限(分页驱动),歌曲数组 ~1.32KB/首 → +100 ≈ +132KB bss
NCM_SONG_BATCH 64 100 song_detail 每批(≤500 安全窗内保守 100;clist[4096] 装 100 首 ≈2KB)
NCM_PLAYLIST_PAGE 50 删除 track/all 服务端分页遗留(该端点 404),改由 NCM_SONG_BATCH 承担页粒度
g_st.track_ids_count — 新增 detail 取回 trackIds 总数,驱动「还有下一页」判据

15. 三期第二批:中文搜索接入拼音 IME + 设置页做好 + 缓存淘汰接线(2026-08-31,agent-241)

范围:仅 patch_v2/ncm_app/(含 core/)。未动 sdk/(主会话在改 meow_ui_theme.c/测试,另有 agent 在改 meow_launcherd/ 主题)与 meow_launcherd/。风控纪律:本批真实 NCM 请求 0 次(全部离线 stub/宿主 mock/ 已落盘样本)。部署/验收留用户(真机验收点见 §15.7)。上一批(歌单分页/封面/歌词锁)已 commit ee104f1, 本批在其上做;提交见 git。

15.1 中文搜索接入拼音 IME

  • 搜索页(app/ui/search.c)输入框 meow_textfield_create(..., MEOW_IME_ASCII) 改 MEOW_IME_PINYIN。 SDK MEOW_IME_PINYIN(M2.88 agent-229 落地)打开后为 4 行字母键 + 组合条 + 候选栏(≤6 候选/页,翻页 + 「清」+「拼/英」键),候选上屏走 py_commit UTF-8 写进字段并触发 on_change(TextField 的 textfield_change_cb 实时回写 g_st.search_query + invalidate)。
  • 候选上屏/清空/退格语义均沿用 SDK IME(py_commit/py_clear/py_back_combo+py_back_field,组合态 UTF-8 安全退格 = 删末字;组合非空时退格删组合末字母),App 侧零改动。
  • 英文输入零回归:拼音键盘含「拼/英」键(K_SWITCH→ASCII)与 ASCII 键盘顶条「中」热区(→拼音), ASCII/NUM 不随本批改动;搜索仍只在用户点「搜索」按钮时发 cloudsearch(风控:进页不自动搜,不逐键发)。
  • 保留 search_submit/search_worker(后台线程 cloudsearch,UI 不卡);ncm_api_cloudsearch 的 keyword 为 UTF-8 字符串,中文关键词直接进 weapi 加密体(无需改造)。

15.2 设置页做好(用户定案:音质/缓存上限可设数字+清理缓存+关于)

app/ui/settings.c 重做为 5 行:音质 / 缓存上限 / 清理缓存 / 退出登录 / 关于网易云。

  • 音质:sheet 列出全 5 档(Hi-Res/Lossless/Exhigh/Higher/Standard,默认 Hi-Res)。单曲不可用时 沿用 core 既有降级链(ncm_api_song_url_auto:hires→lossless→exhigh→higher→standard,ncm_api.c:260), 本批未改 core 降级逻辑。
  • 缓存上限:sheet 预设(1/2/5/10/20/30/50/100 GB)+「自定义...」→ INPUT 弹窗 + NUM IME 数字输入 (input_cache_limit_gb(),atoi + 钳制 1~100)。默认 10GB,上限 100GB。持久化走既有 appstate_save_settings(meow_config_* → <env MEOW_DATA_DIR>/ncm.cache_limit_gb.cfg,即 MeowData/dev.player.ncm/data/,与 core ncm_cache_load_setting/ncm_store_read_cfg_int 同目录同键)。
  • 清理缓存(新):显示当前占用(ncm_cache_total_bytes,进页时算一次存 g_cache_used,清理后清零); tap → CONFIRM 弹窗 → ncm_cache_clear(新 core API)→ snack「缓存已清理」。占用用整数运算格式化 (fmt_bytes,GB/MB/KB,不用浮点/软浮点)。
  • 退出登录 / 关于/版本行:保留;关于 tap 改为 snack「网易云音乐 v0.1 · MeowOS 移植版」。

15.3 缓存淘汰接线(ncm_cache_evict 接进生产路径)

  • core 新增 ncm_cache_clear(core/ncm_cache.c/.h):删除 song_* 全部(mp3 与封面同池,含残留 .tmp),供「清理缓存」与测试用;ncm_cache_evict(既有)保持 LRU=按 mtime 升序删最旧直到 <= limit。
  • mp3 下载路径(app/play/player_manager.c song_local_file):写前(ncm_cache_open 前)与写后 (ncm_cache_commit 后)各调一次 ncm_cache_evict(&g_st.ctx.cache)——写前腾空间(此时本线程 temp 尚未建, 无在途文件被误删风险),写后清「新文件推过上限」的增量。刚写的文件 mtime 最新,LRU 淘汰时存活。
  • 封面路径(app/logic/cover.c cover_worker 未命中写盘分支):写前 + 写后同样各调一次 ncm_cache_evict (封面 song_cov<hash>..jpg 与 mp3 同池)。
  • 手动调低上限:appstate_save_settings 末尾加一次 ncm_cache_evict,用户从 50GB 降到 1GB 立即淘汰。
  • 上限读设置值:ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb)(player_manager_set_quality 与 appstate_save_settings 均已同步,继 appstate_load_settings/ncm_cache_load_setting)。

15.4 验证记录(0 真实 NCM 请求)

项 方式 结果
缓存 LRU 淘汰/清空 bench/cache_evict_sim.c 离线(0 网络,稀疏文件构造,直接驱动 core) 全 PASS:①超上限删最旧(700+300+400MB→删 700 停于 700MB)②删 2 个最旧到 ≤1GB(4×500MB→留 2)③未超上限无操作 ④lookup 刷新 mtime → 被访问的 A 存活、B/C 被淘汰 ⑤set_limit_gb 钳制(0→1、200→100)⑥clear 删 mp3+封面+残留 .tmp(total=0)
歌单分页回归 pagination_sim(0 网络) 全 PASS(130/5191/空 三态无回归;appstate.c 加 ncm_cache_evict 未破坏分页——--gc-sections 丢弃不可达的 save_settings,链接不受影响)
core 宿主测试 test_ncm_core_host(重建 core-host) PASS
core mipsel 测试 qemu-mipsel ./out/test_ncm_core_mipsel PASS
mipsel 构建 bash build_ncm.sh(自有代码 -Wall -Wextra) 0 warning;text 844640 / data 856 / bss 2052068
宿主离屏渲染 build_ncm_host.sh + MEOW_UI_DUMP(mock,0 网络) settings(明/暗 5 行 + 占用 + 版本 + 品牌红脚注)/search(输入框+搜索钮+footer)/home/playlist/player 均正常渲染,无崩溃

15.5 md5(本批改动文件 + 产物)

8a313d27dc7d05fa3a78ce7772436b04  ncm                       # mipsel 静态 App(新)
dc66bdcfb29ba5717b42c72ecbf45049  ncm_host                  # 宿主离屏调试二进制(重编)
be3d906edce4d90ebd9c09d3b7faf814  core/ncm_cache.c          # 新增 ncm_cache_clear
f5810d23709080f4fa39b2fbc242c9e0  core/ncm_cache.h
41ae3f40014f68f7855fed3a0be1558c  app/ui/settings.c         # 设置页 5 行 + 自定义数字 + 清理缓存
9811af8f7150b325225e067748ca50a3  app/ui/search.c           # 输入框 MEOW_IME_PINYIN
2350622237e9fb5d05a5497dd8b87e39  app/play/player_manager.c # mp3 写前/写后淘汰
fb1a74a68950b26a2a4f42281725f37d  app/logic/cover.c         # 封面写前/写后淘汰
a51b6d8477bde5692089caf511eafca1  app/logic/appstate.c      # save_settings 末触发淘汰
ed2f71c52563e812a04fb8a7cc05148d  app/test/test_ncm_app.c   # settings mock 初始化缓存目录
2134ad23a6ee4d1b37120ee8d2e44337  bench/cache_evict_sim.c   # 新增 LRU/清空离线回归

15.6 出入清单(只改 patch_v2/ncm_app/)

修改:
  app/ui/settings.c         # 设置页 5 行:音质(全5档)/缓存上限(预设+自定义数字)/清理缓存(显示占用)/退出/关于
  app/ui/search.c           # 搜索输入框 MEOW_IME_ASCII → MEOW_IME_PINYIN(中文搜索,英文零回归)
  app/play/player_manager.c # song_local_file 写前/写后 ncm_cache_evict
  app/logic/cover.c         # cover_worker 未命中写盘分支 写前/写后 ncm_cache_evict
  app/logic/appstate.c      # appstate_save_settings 末尾触发 ncm_cache_evict(手动调低上限立即淘汰)
  core/ncm_cache.c/.h       # 新增 ncm_cache_clear(删 song_* 全部)
  app/test/test_ncm_app.c   # settings mock 初始化 ctx.cache(「占用」行读真实目录)
  ncm / ncm_host / test/test_ncm_core_host   # 重编产物
新增:
  bench/cache_evict_sim.c   # 缓存 LRU 淘汰/清空 离线回归(0 网络)
  REPORT.md                 # 本批记录(§15)
(未动 sdk/、meow_launcherd/、vendor/;未动设备系统/恢复区;未 killall hiby_player / 未重启设备)

15.7 诚实出入 / 已知限制

  • 真机未部署/未验(风控:0 新增真实请求)。部署链路同 §13.5(scp ncm root@<ip>:/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm.new → ssh root@<ip> '. /etc/profile; mv ...ncm.new ...ncm')。真机验收点: ① 搜索页输入拼音→候选上屏中文→点「搜索」出中文结果(键盘「拼/英」「中」「退格」「清」语义); ② 设置页缓存上限「自定义」数字键盘输入 1~100、改上限后立即 LRU 淘汰;③ 清理缓存显示真实占用、(超上限 磁盘占用下降);④ 音质默认 Hi-Res、VIP/版权曲逐档降级播放。
  • song_<id>..mp3 双点命名是 core 既有约定(ncm_cache_path 用 "song_%s.%s" + ext 带前导点 .mp3) ——本批未改(改动它需动 core 缓存写入路径,风险大于收益);cache_evict_sim.c 已按此约定构造文件, 验证的正是真实生产路径。
  • 跨线程淘汰的极小竞态:cover 线程写盘时,player 线程的下载 temp(song_<id>..mp3.tmp)也匹配 song_ 前缀;但 in-progress .tmp 的 mtime 是「刚写」=最新,LRU 升序下排最末,正常淘汰只删最旧, 不会先删它。仅在「池严重超上限、删净旧文件后仍超」的极端情形才可能触及其(上限 ≥1GB,现实中几乎不 发生);沿用既有「双线程同池无锁」模型(本批未扩范围,未引入新锁)。
  • ncm_cache_evict 上限读 &g_st.ctx.cache:宿主 settings mock 未 ncm_ctx_init(只 init cache dir), ctx.cache.dir 为空 → 占用行显示 0(渲染验证用);真机 appstate_init→ncm_ctx_init 后为真实目录。

15.8 数值权衡(供后续微调)

常量 旧值 新值 说明
设置页行数 4 5 新增「清理缓存」行并在「缓存上限」接「自定义」数字输入
音质 sheet 档数 3(Hi-Res/Higher/Standard) 5(+Lossless/Exhigh) 对齐 core NCM_LEVEL_* 全档 + 降级链起点
ncm_cache_clear — 新增 删除 song_* 全部(mp3/封面/.tmp),供「清理缓存」与测试

16. A2:音频输出迁移 meow_audiod(SINK_AUDIOD 默认后端)+ 无声根因修复(2026-09-01,agent-248)

范围:仅 patch_v2/ncm_app/(含 build 脚本、bench、REPORT 本文件)。未动 sdk/、meow_audiod/、 meow_launcherd/、vendor/。风控纪律:真实 NCM 请求 = autoplay 验证的 1 detail + 1 song_detail (Docu 单首歌单,共 2 次 metadata,0 次 song_url 下载,曲目走已缓存 mp3)。

16.1 迁移内容

  • app/play/audio_out.c 新增 SINK_AUDIOD 后端并设为默认(AUDIOD_DESIGN.md §7 落地):
    • 后端选择(env 驱动):缺省 → AUDIOD(SDK meow_audio_* 连 /usr/data/meow_audio.sock); NCM_AUDIO_SINK=file/null/alsa → FILE(WAV 回归)/NULL(解码 RT)/ALSA 直开; MEOW_AUDIO_DIRECT=1 → ALSA 直开(调试保底,非正式能力)。audiod 不在 → open 返回 -1 (不隐式回退直开),日志记 audiod open failed rc=...。
    • ALSA 保底路径修复两大坑(与 audiod/SDK 同款修法,注释标明勿回退):① hw_params 改 「全放行」初始化(mask 全 1 + interval [0,UINT_MAX])——旧 memset 0 的 interval 等价 「只能取 0」→ 内核 refine 必空 → HW_PARAMS 恒 EINVAL,这正是网易云「播放无声」的根因 (真机 probe 实证);② access/format/subformat mask 先清零再单点(留全 1 会被 choose 成 MMAP_INTERLEAVED → write() EINVAL)。
  • 被抢占处理(player_manager.c):player_manager_start 注册 meow_audio_on_preempt → 回调记 WARN 日志并经 player_manager_pause(1)(g_play 命令通道,持锁)暂停播放+更新 UI 态, 不直接动锁外状态。
  • 暂停/恢复语义对齐:run_song 进 NCM_PC_PAUSED 沿 → audio_out_pause(1) 向 audiod 发 MSG_AUDIO_PAUSE(停 PCM 时钟不丢流);出暂停沿 → audio_out_pause(0) 恢复;恢复时若流已死 (被抢占/断链,SDK 返回负值)→ 自动 close+reopen 重建输出流(解码位置不变)。
  • 播放路径全量日志(meow_log,无声排查永久资产):open 结果/请求参数/audiod 授权 grant_*(open 后 meow_audio_query 落 rate/ch/fmt/underrun)、write 错误(首次+每 100 次节流)、 pause/resume/reopen/close(含累计写帧)、play start/done/switch/seek/stopped 事件。
  • 真机联调 autoplay hook(appstate.c + app.c,非正式能力):/tmp/ncm_autoplay 触发文件 存在且 home 歌单已载 → 后台线程找「已缓存」曲目自动播放(内容=数字则歌单下标起扫 ≤3 个、 =文本则歌单名子串单点匹配如 "Docu")。绝不发 song_url 下载——无缓存命中只记日志退出。 用途:无用户在场时验证「ncm→SDK→audiod→DAC」全链。
  • build_ncm_host.sh:SDK_SRC 补 meow_ui_audio(audio_out.c 新增 meow_audio_* 引用,宿主链接需要)。

16.2 验证记录(分层)

层 项 结果
宿主 build_ncm_host.sh 自有代码 0 warning(仅 SDK meow_ui_vol.c 既有 system() 告警)
宿主 WAV 回归:NCM_AUDIO_SINK=file NCM_AUDIO_OUT=... test_decode MIPSTest.mp3 PASS:8.21s/48k/立体声,WAV 头合法,字节数 44+393984×4 精确一致
宿主 离屏渲染 home/playlist/player/settings/search 全 rc=0 无回归
宿主 bench/audiod_chain_sim.c(新增):起真实 meow_audiod_host(FILE sink 假 socket)驱动真实 audio_out.c+SDK 客户端 14 项全 PASS:OPEN(grant=44100/2ch/S16)→WRITE 51200 帧→QUERY(active=1 written>0)→PAUSE(paused=1)→RESUME→抢占(第二客户端裸发 OPEN→on_preempt 回调触发→旧流 write 失败)→流死自动重开(reopen 可写)→CLOSE(active=0)→audiod WAV 落盘非空
qemu qemu-mipsel ./out/test_ncm_core_mipsel PASS(core 未动,回归)
mipsel build_ncm.sh 0 warning;ncm md5 db5b78ad9e8f40e6306484efe2a08eb3
真机 设备已刷 M2.90(0.7.85),/usr/bin/meow_audiod 在板运行 ✓
真机 ncm 热更(.new+mv),am start 不可用(amsd DECLARE 转发未联调,「pkg not registered」)→ 手动 nohup 起 ✓ 启动/home 歌单 120 正常
真机 autoplay hook:name 'Docu' → playlist[1](1 tracks)→ song[0] id=2048962657 缓存命中 → play ✓ 日志链路正确
真机 audiod OPEN→grant 链:meow_audio tone 拿到 sid=1 grant=44100Hz/2ch/S16 ✓ IPC/协商链设备上正常
真机 持续写帧/underrun 验收:被阻断(见 §16.3 重大发现) ✗ 未达成

16.3 重大发现:M2.90 设备 PCM 播放启动 → 内核硬挂死 → 看门狗重启(4/4 复现)

现象:设备(0.7.85,stock kernel 4.4.94+ #15 2026-01-16,未变)上一进入 PCM 播放启动序列, 数秒内整机硬挂死并重启。复现 4/4:meow_audio tone ×3(含一次无 ncm 的纯 audiod 路径)+ ncm 经 SDK 链播放 ×1。kmsg 流抓取到最后内核日志(死亡点,两次抓取一致):

[  412.104797] hbc3000_set_samplerate codec_type=4 samplerate=44100 spdif=0 ...
[  412.104819] hbc3000_stop
[  412.104865] hbc3000_start
[  412.168307] cs43198_dai_prepare pcm
[  412.371919] hbc3000_start
<<< 日志到此中断,无任何 panic/oops 文本 → 硬挂死(疑似时钟/电源域死锁),随后重启 >>>

归因排查记录:

  • 固件打包无误:设备 /usr/bin/meow_audiod md5 e07e9144… == 仓库构建 == fw_content.conf 条目。
  • ncm 无关:对照组 ncm.bak(A2 前二进制 19d99ce4)同设备前台 idle 31 分钟稳定(boot G, 期间经历 suspend/resume);本会话早段(23:05-23:45)观察到的 4 次「idle ncm 在跑数分钟后重启」 实为用户 M2.90 刷机循环(卡根 r3proii.upt 22:46 上卡,多次升级重启)同窗口巧合,对照组证伪。
  • suspend 相关性未证伪也未证实:设备每次 boot 后 ~40s 自动息屏进 System suspend(dmesg 实证), 而 4 次致命播放全部发生在 resume 之后;A1 收尾(agent-246)同内核 tone 实测 written=played、 underrun=0 通过——据信在未 suspend 的新鲜 boot 窗口。未 suspend 的播放验证需要 「重启后 40s 内立刻 tone」,本会话无法在不主动重启的前提下获得该窗口(留给用户/主会话)。

影响与建议(留主会话/用户决策):

  • A2 真机验收的「持续写帧+underrun=0」被此问题阻断,非 ncm 代码问题(SDK 链 OPEN→grant 已实证: tone 拿到 sid=1 grant=44100Hz/2ch/S16;ncm 日志链到「缓存命中 → play」)。
  • 建议:① 新鲜 boot 40s 内(未 suspend)测 meow_audio tone 验证 suspend 相关性; ② 若为真,权宜=禁用自动息屏(display.screen_off_timeout=0)或 resume 后重置 codec 电源/时钟; ③ 根治需看 hbc3000/cs43198 驱动 resume 路径(stock 内核源码不在本仓库);挂死点恒在 cs43198_dai_prepare 后第二次 hbc3000_start,无任何内核日志 = 硬挂(时钟/i2c/电源域死锁特征)。
  • 本会话为定位该问题触发过多次设备重启(均自动恢复;未碰 recovery_all/未 killall hiby_player/ 未主动重启设备)。

16.4 md5(本批改动文件 + 产物)

db5b78ad9e8f40e6306484efe2a08eb3  ncm                       # mipsel 静态 App(新;已部署设备)

(源文件 md5 以 git 为准;改动清单见 §16.5)

16.5 出入清单(只改 patch_v2/ncm_app/)

修改:
  app/play/audio_out.c        # SINK_AUDIOD 默认后端 + ALSA 保底两坑修复 + audio_out_pause + 全量日志
  app/play/player_manager.c   # on_audio_preempt 注册/回调 + 暂停沿 audiod pause/resume + 播放事件日志
  app/logic/appstate.c        # autoplay 真机联调 hook(/tmp/ncm_autoplay,缓存命中才播,绝不下载)
  app/app.c                   # app_on_tick 挂 autoplay_maybe
  app/ncm_app_internal.h      # audio_out_pause/autoplay_maybe 声明 + audio_out 注释更新
  build_ncm_host.sh           # SDK_SRC 补 meow_ui_audio
  ncm / ncm_host              # 重编产物
新增:
  bench/audiod_chain_sim.c    # audiod 全链离线回归(真实 audiod_host 假 socket,14 项)
  REPORT.md                   # 本批记录(§16)
(未动 sdk/、meow_audiod/、meow_launcherd/、vendor/;未动设备系统/恢复区)

16.6 诚实出入 / 已知限制

  • 真机验收未闭环:§16.3 内核硬挂死阻断「持续写帧 + underrun=0 + meow_audio status 在播」三项。 已实证到 OPEN→grant(CLI tone)与 ncm→autoplay→缓存命中→play 日志链;SDK 客户端全链 (含抢占/重开)由宿主 sim 对真实 audiod_host 14 项覆盖。
  • autoplay hook 默认 0 起扫前 3 个歌单首页:若首个歌单是超大歌单(5191 首 detail ~35s 解析) 会慢/重;文本名单点匹配(如 Docu)是推荐用法。hook 为调试资产保留,不影响正常 UI 路径。
  • am start dev.player.ncm 报 pkg not registered:amsd 二期 launcher→amsd DECLARE 转发未联调 (WORK_STATE 已记残余),本次手动 nohup 起 ncm(fb 与 launcher 并存,测试场景可接受)。
  • 设备日志中 ipc_media META 每秒一对(state=0 空态也发)为既有行为,本批未动。
  • 未碰 /data/recovery_all、未 killall hiby_player、未主动重启设备(§16.3 的多次重启均为 被测路径触发的设备自重启)。

17. 三期第三批:歌曲对象瘦身 + 滑动窗口(任意长度歌单,取消 1000 截断)+ 播放快照(2026-09-01)

范围:仅 patch_v2/ncm_app/(含 core/ 结构体瘦身)。未动 sdk/、meow_launcherd/、 meow_audiod/、vendor/。风控纪律:本批真实 NCM 请求 0 次(全部离线 stub + /tmp 盘缓存)。 本批未部署真机(设备音频线挂死排查中,ncm 二进制不动卡;部署留主会话)。

17.1 动机

§14 的歌单分页(detail 全量 trackIds 驱动 song_detail 分批 100、触底加载)仍受 NCM_SONG_MAX=1000 内存硬上限约束(每首 ncm_song_t ≈1324B → 1000 首 ≈1.32MB bss), 用户「我喜欢」6000+ 首只能看到前 1000。本批把「全量物化」改为滑动窗口:内存只保留 视口附近的歌曲对象(256 首),滚动出窗释放、入窗按需拉取,支撑任意长度歌单。

17.2 歌曲对象瘦身(core/ncm_api.h,1324B → 608B,-54%)

字段 旧 新 依据
id 32 24 数字文本,int64 最大 19 位 + NUL
name 256 128 列表/播放页展示均按宽再截断(ncm_text_fit/listview)
art_name 256 96 首个歌手名
album_name 256 96 同上
al_pic 512 256 picUrl 实际 <200 字符;obj_str 截断安全
duration_ms long int32_t <2^31ms≈24 天;MIPS32 的 long 本就 32 位(零行为变化)

解析侧(parse_songs_array)全部走 sizeof 字段 拷贝,无需改动;core 测试 (test_ncm_core.c 断言 id/name/ar/al/dt/fee/al_pic)宿主 + qemu-mipsel 全 PASS。

17.3 滑动窗口(appstate.c + playlist.c)

  • 内存模型(ncm_app_internal.h):
    • song_ids[NCM_TRACKID_MAX=16384] = detail 取回的全量 trackIds 索引(int64,128KB bss, 不再 1000 截断;16384×~13B ≈ 213KB JSON,远在 2MB 响应限幅内);
    • songs[NCM_SONG_WINDOW=256] + song_valid[256] 位图 = 物化窗口(256×608B≈152KB bss), songs[i] ↔ 全局下标 window_base+i;
    • 列表 count = track_ids_count(全量),未物化行画占位「··· / 加载中」。
  • API(app/logic/appstate.c,全锁 g_song_mu;网络/盘 IO 在锁外,锁内仅内存搬移):
    • appstate_song_ensure_range(lo,hi):窗口未覆盖先滑窗(重叠区 songs+valid 一起 memmove 保留,出窗槽位失效),缺失槽先读盘缓存,miss 攒批 song_detail 补载并落盘;
    • appstate_song_copy(gidx,&out) / appstate_range_loaded(lo,hi):锁内读窗(UI 行回调);
    • appstate_song_fetch(gidx,&out):任意下标物化(窗→盘→song_detail 单曲),不滑窗 (播放线程专用,避免与浏览视口抢窗口造成乒乓);
    • appstate_adopt_songs(n):收编外部已填的 songs[0..n)(搜索结果)为窗口 base=0 播放上下文;
    • 兼容入口 appstate_load_playlist_songs_page(idx,offset,limit):offset=0 重建索引+物化 首段;offset>0 = ensure [offset,offset+limit)。
  • 批网格对齐预取:补载区间对齐到 NCM_SONG_BATCH=100 的全局网格([a0,b0) ≤100), 逐行滚动不产生小请求放大——6000 首全程下滚 song_detail 调用恰 = 60 次(6000/100)。
  • 盘缓存(app/logic/songmeta.c,新):拉过的 song_detail 结果逐首序列化到 <cache_dir>/meta/<id>.bin(头 magic/ver/size + 结构体镜像;ver/size 失配判 miss)。 刻意不进 ncm_cache LRU 池:meta 每首 ≈600B(一万首 ≈6MB),不被清音频缓存误清。 效果:二次进入歌单 / 回滚全程 0 网络请求(离线可浏览已拉过的部分)。
  • UI(playlist.c):pl_maybe_load_more(触底追加)退役 → pl_maybe_ensure(视口 ±24 行 有未物化槽 → 后台 worker ensure);footer 改「视口区间 / 总数」(如 5965-5988 / 6000 首); 首屏失败整页空态 + 点按重试不变;补载失败 snack + 占位行保留(滚动/点按再试)。

17.4 播放兼容(窗口滑出当前播放曲不许崩)

  • 播放快照:g_play.play_song(mu 保护)——run_song 起播时物化目标曲 (song_copy 窗内命中 / song_fetch 窗外)并整体拷入快照;之后下载(id)、时长、 歌词 worker(id)、IPC META(title/artist/album)、播放页 UI(歌名/歌手/封面)全读快照, 与窗口完全解耦。窗口滑出当前播放曲 → 播放/歌词/媒体会话照常。
  • 上下首/自然顺延边界:g_st.song_count(窗口内有效数)→ g_st.track_ids_count(全量索引), cur_song 语义 = 全局下标不变。
  • 占位行可直接点歌:player_play_song(row) 不要求行已物化——封面回落歌单封面, 元数据由播放线程 fetch 后快照接管;UI tick 按快照 id 变化纠正封面/歌名(快照滞后 settle)。
  • 搜索上下文:search_worker 结果经 adopt_songs 收编(song_ids 同步 strtoll), 播放/上下一首对结果集生效(同旧语义)。

17.5 内存对比(6000 首歌单场景)

项 改前(§14) 改后(§17)
ncm_song_t 1324B 608B(-54%)
歌曲对象数组 1000×1324B ≈ 1292KB(且 1000 截断,6000 首只能看前 1000) 256×608B ≈ 152KB(任意长度恒定)
trackIds 索引 1000×8B = 8KB(截断) 16384×8B = 128KB(全量)
有效位图 — 256B
合计(歌曲数据) ≈1300KB(有截断) ≈280KB(无截断)
ncm bss 总量(size) 2052068 1080900(-972KB,-47%)

6000 首全量物化的假想成本 6000×1324B≈7.9MB(14MB 预算下不可行)→ 滑窗后与歌单长度无关。

17.6 验证记录(0 真实 NCM 请求)

项 方式 结果
窗口基本语义 bench/pagination_sim.c(已更新到新语义,离线 stub 驱动真实 appstate) 全 PASS:130 首整装入窗(1 detail + 2 song_detail);5191 首无截断(索引全量 5191、52 批网格、末首可达、出窗槽 copy 失败、越界无操作);空歌单 0
6000 首滚动全程 bench/window_sim.c(新;真实 appstate + 真实 songmeta 盘缓存 /tmp) 全 PASS:首屏 1+1 请求;下滚 750 步(步进 8 行)每步视口 12 行全物化、song_detail 恰 60 次(无放大)、有效槽恒 ≤256;回滚全程 +0 请求(盘缓存全命中,6000 meta 文件落盘);窗口滑出播放曲后 song_fetch 不滑窗不崩、盘命中免请求、尾部/越界钳制正确
core 回归 build_core.sh + test_ncm_core_host + qemu-mipsel out/test_ncm_core_mipsel 宿主 + qemu 全 PASS(结构体瘦身不影响解析断言)
mipsel 构建 bash build_ncm.sh(自有代码 -Wall -Wextra) 0 warning;text 865604 / data 1480 / bss 1080900
宿主离屏渲染 build_ncm_host.sh + MEOW_UI_DUMP(mock,0 网络) home/playlist/player/lands/settings/search/login 渲染正常:歌单 6 行 + footer 1-6 6(视口区间格式)、黑胶 + 快照歌名/歌手 + 歌词高亮;自有代码 0 warning(仅 SDK meow_ui_vol.c 既有告警)

17.7 md5(本批改动文件 + 产物)

b32e8a80c12de4dac2e4ad52d26ae55c  ncm                       # mipsel 静态 App(新;未部署)
783d3ddf9bfc1905f732f688c54c5447  ncm_host                  # 宿主离屏调试二进制
321039c8743af6ccd934c63a3a161e84  app/ncm_app_internal.h
bde871b33298cf25187054650560754a  core/ncm_api.h
3c4122e72cc701aa5d0d95862b0201d1  app/logic/appstate.c
dd8fbb70ba5e0978fa75501135c473a3  app/logic/songmeta.c
cb44e2385108ba6cdad242aecf635b64  app/ui/playlist.c
533dafebce47bd60355a1efc7719db9c  app/ui/player.c
a5447ac5e6f2ba83405a7a3c19c88dc9  app/play/player_manager.c
74eded2b9a220aba9a50832ff9c6914c  app/play/ipc_media.c
d2c2505eede96ddb1387209681894b83  app/ui/search.c
564630846354d3e202de303435e82c59  app/test/test_ncm_app.c
00a1445f83e98a068c4219593bb7d6d8  bench/pagination_sim.c
b2b6869990c8ef3906c100f18c7194e8  bench/window_sim.c
3159c53adf59967cacdad50a58946858  build_ncm.sh
430211ee6895c26c9871da22e9aede76  build_ncm_host.sh

17.8 出入清单(只改 patch_v2/ncm_app/)

修改:
  core/ncm_api.h              # ncm_song_t 瘦身 1324B→608B(id24/name128/art96/al96/pic256/dt int32)
  app/ncm_app_internal.h      # 删 NCM_SONG_MAX;+NCM_TRACKID_MAX 16384 / NCM_SONG_WINDOW 256 /
                              #   song_valid[]/window_base;g_play.play_song 快照;窗口 API + songmeta 声明
  app/logic/appstate.c        # 滑动窗口(g_song_mu/滑窗 memmove/批网格预取/盘缓存补载)+
                              #   song_copy/range_loaded/song_fetch/adopt_songs + 兼容入口改窗口语义 +
                              #   autoplay 走 song_copy + logout 清窗口 + strncpy→ncm_strlcpy 消告警
  app/ui/playlist.c           # 触底分页退役 → 视口 ensure(pl_maybe_ensure)+ 占位行 + footer 视口区间
  app/ui/player.c             # 元数据读播放快照(cur_song_meta)+ 快照 id 变化纠正封面/歌名 +
                              #   占位行可点(播放线程物化)
  app/play/player_manager.c   # run_song 起播快照 + song_local_file 按 id + 边界 track_ids_count +
                              #   歌词 worker 读快照 + player_manager_current_song 访问器
  app/play/ipc_media.c        # META title/artist/album 读播放快照
  app/ui/search.c             # 结果经 appstate_adopt_songs 收编为窗口播放上下文
  app/test/test_ncm_app.c     # mock 补窗口字段(song_valid/track_ids_count)+ 播放快照
  bench/pagination_sim.c      # 更新到窗口语义(5191 无截断/出窗 copy 失败/网格批数)
  build_ncm.sh / build_ncm_host.sh  # +app/logic/songmeta.c
  ncm / ncm_host              # 重编产物
新增:
  app/logic/songmeta.c        # 歌曲元数据盘缓存(<cache_dir>/meta/<id>.bin,magic/ver/size 头)
  bench/window_sim.c          # 6000 首滚动全程离线验证(0 请求,盘缓存回滚 0 新增请求)
  REPORT.md                   # 本批记录(§17)
(未动 sdk/、meow_launcherd/、meow_audiod/、vendor/;未部署真机;未动设备系统/恢复区)

17.9 诚实出入 / 已知限制

  • 真机未部署/未验(风控 + 设备音频线排查中;部署留主会话)。真机验收点:① 6000+ 歌单 全程下滚/回滚无占位残影、footer 视口区间随滚动更新;② 占位行直点能播(fetch 物化); ③ 播放中猛滑列表把当前曲滑出窗 → 播放/歌词/媒体卡片不断;④ 二次进入歌单离线可浏览 已拉过的部分(meta 盘缓存);⑤ 歌单 >16384 首时 trackIds 索引截断(footer 总数仍对, 尾部不可达——16384 是 128KB bss 的取舍,当前用户最大歌单 6000+ 有足够余量)。
  • 单写者假设:ensure/page 仅由 playlist worker 驱动(g_plthr_on 互斥),播放线程 song_fetch 不写窗口;搜索 worker 的 cloudsearch 直接写 songs[] 前半再 adopt_songs 收编(与旧代码同款的单写者时序,未加新锁)。
  • 快照滞后一瞬:切歌瞬间(next/prev/占位行直点)播放页歌名/封面仍是旧曲快照, play 线程物化后(通常 <100ms,窗外需一次盘读/网络)由 tick 按快照 id 纠正——一帧级旧 内容,与 §14.7 歌词锁残余同级接受。
  • 切歌单时播放上下文:打开新歌单会重建索引/清窗口(同 §14 既有行为),正在播的旧歌单 曲目靠快照续播(比旧模型更安全:旧模型 memset 后 id/dur 全丢);但 cur_song 下标语义 随新歌单索引变化(既有竞态,未扩大)。
  • meta 盘缓存无版本化清理:结构体再改版靠 ver/size 判 miss 自动重建;无 TTL (歌曲改名/换封面不主动失效——与 ncm_cache 同级的「缓存可再生」取舍)。
  • host 构建有 SDK meow_ui_vol.c「system 返回值忽略」告警(SDK 源,非本 App;mipsel musl 无)。

18. 三连修:封面切歌不更新 + 缓冲无反馈/失败无声 + 暂停/切歌边界稳健性(2026-09-01,用户三连报)

范围:仅 patch_v2/ncm_app/(ui/play/logic/bench/test + 本文件)+ 根 .gitignore(新增 3 个 sim 二进制忽略条目)。未动 sdk/、meow_launcherd/、meow_audiod/、vendor/。 风控纪律:本批真实 NCM 请求 0 次(全部离线 stub/sim;并新增 mock 防真网守卫,见 §18.5)。 本批未部署真机(部署留主会话统一做)。

18.1 问题①:封面只在第一首歌出现、切歌不更新 —— 根因(三因叠加)

  1. cover worker 忙等死循环(主因,CPU 饥饿):cover_worker 换入成功后不清目标 URL (g_tgt_url 残留),主循环每圈 缓存读 200KB + JPEG 解码 + free/换入 + g_gen++,成功路径 无 sleep → 首张封面到位后 worker 永久 100% CPU;同时 cover_changed() 恒真 → 播放页 tick 每帧整盘重绘(黑胶行扫 sqrt + cover blit)。单核 MIPS 上 UI 线程被饿到极低帧率, 慢网络下新封面请求(10s 超时级)看似永不完成。sim 实证:旧代码 settle 后 1.2s 内 cover_changed 翻动 119 次(新代码 0 次)。
  2. 封面兜底值污染(确定性子根因):player_play_song 把本曲 al_pic 经 appstate_set_playing_song 写进 g_st.cover_url(本意=歌单封面兜底)。此后 al_pic 缺失的 歌曲回落到的是上一首的专辑图 URL,且撞 cover_request 去重(== 当前展示)→ 封面永远 停在上一首。
  3. 绘制期 UAF:cover_get() 无锁返回裸指针,UI 绘制期间 worker 换入即 meow_img_free 同一张图(忙等循环下每 ~1/100s 一次)→ use-after-free/撕裂绘制风险。

修法(cover.c + player.c + playlist.c):

  • worker 换入成功即 g_tgt_url[0]=0 转怠速(250ms 轮询);下载/解码失败指数退避 0.5s→4s、连续 8 次放弃并清目标(UI 重进页会重新提交),每次失败 WARN 日志(不再无声)。
  • cover_request 去重加判「在拉目标」(g_tgt_url)+ g_host_mock 直接返回(0 网络纪律)。
  • cover_get() 改持锁返回 + 新增 cover_put(),两个绘制调用点(player 黑胶/playlist 头块)配对使用,UAF 闭合。
  • player_play_song 不再用 al_pic 污染兜底(appstate_set_playing_song(idx, NULL)), g_st.cover_url 回归「歌单封面兜底」单语义。
  • plr_page_enter 清零 g_last_meta_id/g_last_song 强制重估(重进播放页自愈: 离开期间切歌/封面失败后回来首个 tick 即重请求当前曲封面)。
  • pl_page_enter 补请求歌单封面——旧版 playlist_open 只 cover_cache_reset 清掉、无人 再请求 → 歌单头块封面永远占位 ♪(顺手回填,缓存命中后开销≈一次盘读+解码)。

18.2 问题②:点歌后全程无反馈、失败无声死等 —— 根因与修法

  • 下载本就不在 UI 线程(核查结论):song_url 获取 + 流式落盘全在播放线程 (run_song→song_local_file),UI 无阻塞点;「像卡死」= ①无缓冲 UI ②错误无出口 ③问题①的 CPU 忙等拉低整机帧率 三感叠加。
  • g_play.err/errmsg 无任何 UI 消费者(player_manager_error() 全仓零调用)→ VIP/版权/超时全部无声。修法:
    • 播放页歌词区三态:NCM_PC_BUFFER →「缓冲中… N%」(新增 g_play.dl_done/dl_total, 下载回调锁内累加,song_url 的 size 做分母,未知则转 KB;tick 按 ~2KB 粒度刷动态带); err!=0 → 红字中文错误条(无音源 VIP/版权 / 登录过期 / 网络失败)+「点播放键重试」, 错误沿弹 snack;有词时歌词照旧。
    • 播放键在 STOPPED 且有当前曲(出错后)= 重试(player_manager_play(cur)), 图标在 PAUSED/STOPPED 画三角、PLAYING/BUFFER 画暂停。
    • player_manager_play/next 清 err 并重置下载计数(旧错误不残留)。

18.3 问题③:暂停/恢复/切歌边界稳健性

  • 写失败连环跳歌(真根因级):audio_out_write 失败旧版静默 break → 外层当 「自然播完」next(1) → 流死时每首都写失败 → 连环跳完整个歌单且每跳一首发一次 song_url 请求(风控隐患 + 用户看到的「放不了」)。修:写失败 set_err(NCM_ERR_IO) → STOPPED 停住、cur 不动、UI 出错误条。sim 实证:旧代码写失败后 err=0、cur 冲到 -1; 新代码 err=NCM_ERR_IO、cur 不变、writes=1 即停。
  • 下载途中切歌竞态:旧版旧曲下载完成后继续开解码/开音频并无条件 state=PLAYING + 写过期快照 → 把新曲的 BUFFER 覆盖成 PLAYING(UI 假「在播」)+ 封面/歌名 短暂回跳。修:still_current() 护栏——物化后/下载失败后/下载完成后三处校验 cur_song==idx && state==BUFFER,否则静默回外层(缓存已落盘不亏)。sim P4 实证新旧差异。
  • 流死重开链(A2 已有 resume 自动 reopen)UI 侧闭环:被抢占→PAUSED(播/停图标正确)、 resume reopen 失败 → STOPPED+错误条(可重试);audiod pause rc=-9 路径不动(pause 失败 仅记日志,恢复时走 reopen)。
  • 已知边界(不改):下载/ song_url 不可中途取消(ncm_http 无 cancel),切歌响应最坏 多等一个在途请求超时(30s 上限,正常秒级);BUFFER 态点暂停无效(语义:缓冲未开播)。

18.4 验证记录(0 真实 NCM 请求)

项 方式 结果
封面链 bench/cover_chain_sim.c(新;真实 cover.c+ncm_cache,stub HTTP/假 img) 新代码 7 组全 PASS:首请求出图/定居零忙等/切歌换图+cover_current.jpg 同步/去重/失败×2退避成功/连败 8 次放弃保留旧图/空 URL+reset。旧代码对照:T2 忙等 119 次/1.2s FAIL(坐实根因①)
播放状态机 bench/player_flow_sim.c(新;真实 player_manager.c 播放线程,stub 解码/音频/网络) 新代码 P1-P5 全 PASS:自然顺延(err=0)/写失败置错停住不跳歌/无音源错误条/下载中切歌无过期状态覆盖/错误后重试。旧代码对照:P2(err=0+cur=-1 连环跳歌)、P4(旧曲覆盖 PLAYING+过期快照)FAIL
audiod 链回归 bench/audiod_chain_sim(真实 audiod_host 假 socket) 14/14 PASS(含抢占/流死重开,问题③链路回归)
滑窗回归 pagination_sim + window_sim(重编新 appstate.c) 全 PASS
core 回归 test_ncm_core_host + qemu-mipsel out/test_ncm_core_mipsel 全 PASS
宿主构建 build_ncm_host.sh 自有代码 0 warning(仅 SDK meow_ui_vol.c 既有告警)
宿主离屏渲染 home/playlist/player/playerbuf/playererr/settings/search/login/lands 全 rc=0;playerbuf 出「缓冲中… 25%」位(宿主 mock 字体无 CJK,中文留白为既有宿主限制,设备 GBK 字库正常)、playererr 错误条+播放三角
mipsel build_ncm.sh(自有代码 -Wall -Wextra) 0 warning;text 868772 / bss 1080900(与 §17 持平)

18.5 md5(本批产物 + 改动文件)

50d41bbf4a379e925699ba013b997ffd  ncm        # mipsel 静态 App(新;未部署,留主会话)
87de141c6a2719bb2b93807f282077de  ncm_host   # 宿主离屏调试二进制
6715607f561c5df83b251569b0ae2b7c  app/logic/cover.c
d6aee631734622526befb977d4e3cddf  app/logic/appstate.c
ca22772678de6e293747c9ef4890b80f  app/ncm_app_internal.h
2d0eb786ec78f48e7e0625f4bd7a74c9  app/play/player_manager.c
d2bdfbed5569af760119d0ffb785e43e  app/ui/player.c
c596763af26fd0c687286a250fccd90a  app/ui/playlist.c
08fc30f6ddf77c638ca4a8f3a20aef0e  app/test/test_ncm_app.c
30b163bfc2c880b819f8168b10895161  bench/cover_chain_sim.c
fee4204e945866f5a07ca7d191fa83e3  bench/player_flow_sim.c

18.6 出入清单

修改:
  app/logic/cover.c        # worker 成功清目标转怠速(修忙等死循环)+失败指数退避 8 次放弃+
                           #   WARN 日志+去重加在拉目标+g_host_mock 防真网+cover_get 持锁/cover_put(修 UAF)
  app/ui/player.c          # 缓冲三态 UI(缓冲中…N%/错误条+重试)+播放键 STOPPED=重试+enter 自愈重估+
                           #   下载进度 tick 刷新+错误沿 snack+cover_put 配对+不污染封面兜底
  app/ui/playlist.c        # cover_put 配对+pl_page_enter 请求歌单封面(头块回填)
  app/play/player_manager.c# dl_done/dl_total 进度+play/next 清 err+still_current 切歌护栏三处+
                           #   写失败 set_err(修连环跳歌)
  app/logic/appstate.c     # set_playing_song 注释对齐(cover_url 仅兜底语义)
  app/ncm_app_internal.h   # cover_put/player_manager_dl 声明+g_play.dl_*+cover_get 语义注释
  app/test/test_ncm_app.c  # mock 新页 playerbuf/playererr(缓冲/错误态离屏渲染)
  .gitignore               # window_sim/cover_chain_sim/player_flow_sim 二进制忽略
  ncm / ncm_host           # 重编产物
新增:
  bench/cover_chain_sim.c  # 封面链离线回归(7 组,含旧代码对照实证忙等根因)
  bench/player_flow_sim.c  # 播放状态机离线回归(P1-P5,含旧代码对照实证跳歌/竞态根因)
  REPORT.md                # 本批记录(§18)
(未动 sdk/、meow_launcherd/、meow_audiod/、vendor/;未部署真机;未动设备系统/恢复区)

18.7 诚实出入 / 遗留

  • 真机未部署未验(留主会话)。真机验收点:①切歌封面跟随(含离开播放页再进)②点歌即见 「缓冲中… N%」、失败出中文错误条+可点播放键重试 ③歌单头块封面回填 ④退出播放页再进无残旧封面。
  • 切歌响应延迟下限:在途 song_url/下载不可取消(ncm_http 无 cancel 口),连切时旧请求 跑完才轮新曲(正常秒级;30s 超时兜底)。要更低延迟需 core 层加取消位,本批未动 core。
  • 封面失败放弃后靠「重进播放页/切歌」重新武装(enter 自愈),无定时重试——弱网长期 挂机时封面可能缺席但不影响播放。
  • 播放页 BUFFER 态进度条仍画 0:00 标尺(缓冲文案在歌词区),未做进度条一体化——观感项。
  • meow_audiod_host 工作区有他人改动(其他 agent 在改 audiod),本批未 add/未提交该文件。

19. FLAC 解码落地(dr_flac)+ 缓存键 = 歌曲ID+音质档位(2026-09-01)

范围:仅 patch_v2/ncm_app/ + 根 .gitignore(vendor/dr_flac 例外条目)。未动 sdk/、 meow_audiod/、meow_launcherd/。风控纪律:本批真实 NCM 请求 0 次(全部离线 stub/sim/ 自制测试样本;song_url 降级链只改接受条件,未新增请求路径)。 本批未部署真机(部署留主会话统一做)。

19.1 动机

播放链此前只有 minimp3(mp3-only):song_url_auto 只收 mp3 URL,无损 SQ/Hi-Res 档位 拿到 flac 也只能继续降级 → 无损/Hi-Res 空转(NCM_CACHE_DESIGN §2.5「同日发现」)。 硬件侧 CS43198 支持到 768kHz/S24,audiod 已支持 S16/S24/S32 输入 + DAC 原生率跟随 (AUDIOD_DESIGN §4),缺的只是解码器。本批引入 dr_flac 并打通全链。

19.2 dr_flac 引入(vendor/dr_flac/dr_flac.h,入库)

  • 来源:https://github.com/mackron/dr_libs(master 分支 dr_flac.h,2026-09-01 取得, 532768 B,版本宏 0.13.4);许可证 public domain / MIT-0 任选(文件头/尾双许可声明)。
  • 单头文件直接入库:.gitignore 由 patch_v2/ncm_app/vendor/ 改为 vendor/* + !vendor/dr_flac/ 例外(其余 vendor 大树维持不入库,VENDOR_NOTES.md 记版本/获取方式)。
  • 集成:app/play/dr_flac_impl.c 唯一实现 TU(DR_FLAC_IMPLEMENTATION + DR_FLAC_NO_OGG——song_url 下发的 flac 恒原生封装,裁 ogg 容器省 text), 编译豁免告警(与 minimp3 同口径)。

19.3 解码分发(app/play/decoder.{h,c},新)

  • ncm_dec_open 按 magic 分发:首 4 字节 fLaC → dr_flac;其余(ID3 / 0xFF 帧同步/ 未知)→ minimp3(未知格式由 minimp3 open 报错兜底,不加第三分支)。
  • 统一接口:open(回填 rate/ch/bits/total_frames) / read(帧) / seek_ms / close / format。位宽映射:flac ≤16bit → S16;>16bit → S32 左对齐满幅(drflac s32 输出), 送 audiod AUDIO_FMT_S32——格式归一/SRC-free 原生率跟随全在 audiod 侧(AUDIOD §4), 本层零裁剪零 SRC。mp3 恒 S16。
  • mp3 细节:minimp3_ex 的 dec.info 首读后才有效 → open 时预读一块进 stash(4096 帧), read 先吐 stash;seek 保持旧语义(关+重开按字节近似)。flac seek = 精确 PCM 帧。
  • player_manager.c run_song 改走 decoder:audio_out_open(rate, ch, bits) 按实际流 (不再硬编码 44100/2/16);进度 pos/暂停日志/seek 帧数全部用实际 rate; song 元数据 duration 缺失时用 total_frames/rate 推准确时长(flac 精确)。 单块 PCM 缓冲改静态 32KB(4096 帧 ×2ch ×4B S32 上限;播放线程单实例,省栈)。

19.4 audio_out 位宽参数化(S32 通路)

  • audio_out_write 签名 const short * → const void *(字节数按 open 的 bits×ch 算, FILE/ALSA 两后端本就如此)。
  • AUDIOD 后端:bits 16→AUDIO_FMT_S16、32→AUDIO_FMT_S32(其余拒绝);流死重开 (pause resume 失败路径)按本次 bits 还原格式(旧版恒 S16,flac 曲重开会错格式——随本批修)。
  • ALSA 直开保底:支持 S32_LE(16/32);WAV sink 头 bits 字段如实写(16/32 均合法 PCM WAV)。

19.5 song_url 降级链接收 flac(core/ncm_api.c)

  • ncm_api_song_url_auto 接受条件:type ∈ {mp3, flac};type 缺失(旧端点兼容)时看 url 扩展名(.flac → 回填 type=flac),默认当 mp3 收。降级语义不变(hires→lossless→ exhigh→higher→standard 逐档,url null/未知格式继续降)。
  • 下载限幅:Hi-Res flac 单曲可达数百 MB(192k/24bit 5min ≈345MB)→ flac 上限 1GB、 mp3 维持 64MB(u.size 可信时按 size 限幅)。

19.6 缓存键 = 歌曲ID + 音质档位(用户定案 NCM_CACHE_DESIGN §2.5)

  • 新键:song_<id>_<level>.<ext>(level = ncm_api_level_str:standard/higher/exhigh/ lossless/hires)。同歌同档命中不重复下载;换档=新条目;旧档留给 LRU 自然淘汰(不主动删)。 键取用户设定档(非实际降级产物档)——保证「同设定永不重下」,降级产物归设定档键。
  • 查找顺序:新键 flac → 新键 mp3 → 旧无档位键 song_<id>.mp3(存量温和兼容,命中即播)。
  • 命名正名:ncm_cache_path 剥离 ext 至多一个前导点——旧版调用方传 ".mp3" 生成 双点名 song_<id>..mp3(§256 修的既有约定),新文件一律单点;ncm_cache_lookup 单点 miss 时回退历史双点名(命中照常刷 LRU,不改名不删除)。封面池(song_cov*.jpg) 同享正名+双点回退。
  • autoplay hook(/tmp/ncm_autoplay)的「已缓存」判定同步三键顺序。

19.7 验证记录(0 真实 NCM 请求)

测试样本:libsndfile(venv soundfile)自制 out/test-vectors/sweep_44k1_16.flac (44.1kHz/16bit/2ch/3.000s=132300 帧)与 sweep_96k_24.flac(96kHz/24bit/2ch/2.000s= 192000 帧)(正弦扫频;out/ 不入库,脚本见 §19.9 可复现)。

项 方式 结果
flac 44.1k/16 宿主 test_decode(新:走 decoder 分发→audio_out FILE WAV),expect 132300/44100/16 PASS:帧数/率/位宽精确;WAV 数据与 libsndfile 参考逐样本零误差
flac 96k/24 宿主 同上,expect 192000/96000/32 PASS:S32 满幅输出,换算 float 与参考 max err = 0.0;173× RT
mp3 回归 宿主 MIPSTest.mp3 expect 393984/48000/16 PASS:与 §16 记录逐帧一致(393984 帧/8.208s),WAV 字节数精确
flac+mp3 qemu-mipsel test_decode_mipsel(静态交叉)同三样本 全 PASS:44.1k 50.9× RT / 96k24 29.0× RT / mp3 4.8× RT(qemu 模拟保守值,真机更快);MIPS32 LE 端序/32位 long 无问题
播放状态机 bench/player_flow_sim.c(stub 换 ncm_dec_*;新增 P6:档位键+flac ext) P1-P6 全 PASS:P6 实证 cache key=1000_lossless、type=flac → .flac 落盘;P1-P5 旧用例零回归
缓存键/命名 test_ncm_core(新增:ext 带点正名单点路径 + 历史双点名 lookup 命中)+ cache_evict_sim 宿主 + qemu-mipsel 全 PASS;evict 6 组 PASS(song_* 前缀扫描不受键变化影响)
封面/滑窗回归 cover_chain_sim / pagination_sim / window_sim(重编新 ncm_cache.c/appstate.c) 全 PASS(封面池同享正名+双点回退;autoplay 三键判定 gc 段不影响 sim)
audiod 链回归 audiod_chain_sim(真实 audiod_host 假 socket,重编新 audio_out.c) 14/14 PASS(OPEN/WRITE/PAUSE/RESUME/抢占/流死重开/CLOSE)
mipsel 构建 build_ncm.sh(自有代码 -Wall -Wextra;dr_flac_impl vendor 豁免) 0 warning;text 954304 / data 1576 / bss 1113668(text +85.5KB = dr_flac+decoder)
宿主离屏渲染 build_ncm_host.sh + 9 页 MEOW_UI_DUMP 全 rc=0;自有代码 0 warning(SDK meow_ui_vol.c 既有告警除外)

19.8 md5(本批产物 + 改动文件)

52d20f5325ba2bb646355dcc6db54ad3  ncm        # mipsel 静态 App(新;未部署,留主会话)
ff7acea1b1769fb2e67a5177dfc37d8f  ncm_host   # 宿主离屏调试二进制
90c7901bff0ff6e2e579ef068ac48161  vendor/dr_flac/dr_flac.h
86e99aaad27bfc828a59694022e4a8aa  app/play/decoder.c
b02e8e5f7c7194a6bf492171c137233f  app/play/decoder.h
2c6d5b93cecec980a67b737329847905  app/play/dr_flac_impl.c
6b8b0d13933a547c3349e423a9516eb3  app/play/player_manager.c
6f5ea39ab6b7ef54c87c48916c8705eb  app/play/audio_out.c
2ad8b12f47014d034df75f864cb2a9b9  app/ncm_app_internal.h
2ca5b164877617063a7a44964acdf3d8  core/ncm_api.c
de21f105514235594f90427beec0b10f  core/ncm_api.h
b870b25c9bd9692c912170ddce2b04de  core/ncm_cache.c
0cb40a5af7f1c85af79f4f4b44a9cc4a  core/ncm_cache.h
811c730b5fc52256f84b8917b7657649  app/logic/appstate.c
fba1d01fe6f3cfc9bcf334481e8edb09  app/test/test_decode.c
0ed4ce768d82a9512c5d32bca1271366  test/test_ncm_core.c
c6846cb4c31161c42d81560ade628771  bench/player_flow_sim.c
791a9ddbde363348e3ef373931647aeb  build_ncm.sh
f4fb97416868b9a61b6b54f8958bc6f9  build_ncm_host.sh
65e3da445996e62079bfa38a9c9699b7  VENDOR_NOTES.md

19.9 出入清单(只改 patch_v2/ncm_app/ + 根 .gitignore)

新增:
  vendor/dr_flac/dr_flac.h    # dr_flac 0.13.4 单头(public domain/MIT-0;入库例外)
  app/play/decoder.h/.c       # 格式分发解码(magic:fLaC→dr_flac / 其它→minimp3;S16/S32 位宽映射)
  app/play/dr_flac_impl.c     # dr_flac 唯一实现 TU(DR_FLAC_NO_OGG;vendor 豁免告警)
修改:
  app/play/player_manager.c   # run_song 走 decoder(实际 rate/ch/bits/时长)+ 缓存键 id+档位 +
                              #   flac 落盘(ext 按 type/url)+ flac 下载限幅 1GB + 旧无档键兼容
  app/play/audio_out.c        # write 改 const void* + S32 通路(AUDIOD/ALSA/WAV)+ 流死重开按 bits 还原
  app/ncm_app_internal.h      # audio_out_write 签名/注释
  core/ncm_api.c/.h           # song_url_auto 接受 flac(type 权威/url 扩展名兜底),降级语义不变
  core/ncm_cache.c/.h         # ext 前导点正名(单点)+ lookup 历史双点名回退
  app/logic/appstate.c        # autoplay 已缓存判定三键顺序(新档键 flac/mp3 → 旧无档键)
  app/test/test_decode.c      # 改走 decoder 分发 + expect_frames/rate/bits 校验 + meow_ui_time_ms 桩
  test/test_ncm_core.c        # 缓存命名正名 + 双点旧名兼容用例
  bench/player_flow_sim.c     # stub 换 ncm_dec_* + ncm_api_level_str/ncm_streq 桩 + P6(档位键/flac ext)
  build_ncm.sh / build_ncm_host.sh  # +decoder.c +dr_flac_impl.c + -Ivendor/dr_flac
  VENDOR_NOTES.md             # dr_flac 条目(来源/版本/许可/集成方式/实测)
  .gitignore(仓库根)         # vendor/ → vendor/* + !vendor/dr_flac/(单头入库)
  ncm / ncm_host              # 重编产物
(未动 sdk/、meow_audiod/、meow_launcherd/、其余 vendor/;未部署真机;未动设备系统/恢复区)

19.10 诚实出入 / 遗留

  • 真机未部署未验(留主会话统一)。真机验收点:①无损/Hi-Res 档位真实出 flac 流并出声 (48k/16 与 96k/192k/24 各试一首,日志 play: start ... fmt=flac <rate>Hz/2ch/16|32bit + audiod grant 跟随原生率)②换音质档后同歌重下新条目、旧档仍在(LRU 未触发时)③旧缓存 (无档键/双点名)直接可播不重下 ④flac seek 精确帧。
  • 无损档位要会员:song_url 的 lossless/hires 档需 CVIP/SVIP——用户账号能否真拿到 flac URL 待真机按账号实测(非会员会逐级降到 exhigh mp3,行为同旧版,不算回归)。
  • 八档音质体系(NCM_CACHE_DESIGN §3 的 jyeffect/sky/jymaster 等 SVIP 档)未进 App (设置仍 5 档,枚举只到 NCM_LEVEL_HIRES)——缓存键已按档字符串泛化,后续加档零改动。
  • dr_flac 只接原生 flac 封装(NO_OGG);song_url 若下发 ogg-flac 会被 sniff 判给 minimp3 报错「decode open failed」——目前未见该形态(type=flac 恒原生),真机若见再加 ogg。
  • 96k/24 → S32 满幅在 qemu 下 29× RT,真机 MIPS 预估实时余量充足;若实测吃紧可开 DR_FLAC_NO_CRC(省 CRC 校验,~5-10% CPU)。
  • 测试样本(out/test-vectors/)与宿主/ffmpeg 工具链(out/tools/)在 out/ 下不入库; 样本可用 python soundfile 一键重造(sweep 参数见 §19.7)。

20. 音质体系全量落地:八档 UI + VIP 检测/解锁 + 逐级降级下首恢复 + fee 门禁 + 默认极高 HQ(2026-09-01)

范围:仅 patch_v2/ncm_app/。按用户定案 NCM_CACHE_DESIGN §3(八档 UI 权威)+ §2.1 (VIP 缓存策略)+ CNMPLAYER_BIZ_NOTES §1/§2/§3(端点/9 档映射/fee 口径)实现。 风控纪律:本批真实 NCM 请求 0 次(vip 解析/降级链全部离线样本 + stub 矩阵)。 本批未部署真机(部署留主会话统一做)。

20.1 档位模型(core/ncm_api.h)

  • 枚举扩到全档表,枚举值顺序 = 降级顺序(高→低): `SURROUND(8) > SKY(7) > JYMASTER(6) > JYEFFECT(5) > HIRES(4) > LOSSLESS(3) > EXHIGH(2)

    HIGHER(1) > STANDARD(0);DOLBY=16映射保留但**不进 UI 清单、不进降级链**;HIGHER`(较高 192k)仅降级链中间档,不进 UI。

  • UI 八档 ↔ level 串 ↔ 门槛(BIZ_NOTES §2.1 对齐 CNMPlayer 串;中文名以设计文档 §3 为准):

    UI 档 level 串 规格 门槛
    标准 standard 128kbps 免费
    极高 HQ exhigh ≤320kbps 免费(默认档)
    无损 SQ lossless 48kHz/16bit CVIP
    Hi-Res hires 192kHz/24bit CVIP
    高清臻音 Spatial jyeffect 96kHz/24bit SVIP
    臻音全景声 Audio Vivid sky Audio Vivid SVIP
    超清母带 Master jymaster 192kHz/24bit SVIP
    沉浸环绕声 Surround sky + immerseType=c51 5.1 声道 SVIP
  • ⚠️ sky 歧义处理(BIZ_NOTES §2.1 命名歧义的落地决策):CNMPlayer 无独立 Audio Vivid 档, sky 是唯一串且其附加参数 immerseType=c51 恰为 5.1 声道 → 臻音全景声=裸 sky、 沉浸环绕声=sky+c51(song_url 内按档附加)。两档 API 串相同 → 缓存键改用新 ncm_api_level_key()(sky / sky_c51 唯一后缀),杜绝两档互串缓存。

  • ncm_api_level_gate():standard/higher/exhigh=NONE,lossless/hires=CVIP,其余=SVIP。

20.2 VIP 检测与落盘(core vip/info + app/logic/account.c)

  • ncm_api_vip_info(weapi):先 /api/music-vip-membership/client/vip/info(v2,{userId}), 失败/解析失败再 /front/vip/info(v1)兜底——每个触发条件最多 2 次请求,不轮询。
  • 判定(ncm_api_parse_vip_info,离线可测):root=data 或 body 本身; associator(黑胶)vipCode>0 / isSign / expireTime>now → SVIP; musicPackage(畅听/音乐包)同三判据 + 顶层 redVipLevel/redplusLevel/musicVipLevel/ vipCode>0 或 vipStatus/isVip=true → CVIP;SVIP ⊇ CVIP。到期时间双字段原样记录。
  • 落盘 MeowData/dev.player.ncm/data/account.json:{uid,nickname,vip,cvip_expire_ms, svip_expire_ms,checked_at}(原子写;启动 account_load 恢复先用缓存渲染)。
  • 刷新时机(account_vip_maybe_refresh,调用方 worker 线程上下文,UI 零网络): ①登录成功后(login worker,force)②checked_at 超 1 天 ③会员到期时间已过 (可能自动续费)——每条件每次启动只查一次(单飞标志,失败也置位:防风控, 等下次启动/下个条件)。home_worker 歌单拉取成功后触发 ②③ 判定。
  • account_vip() 为纯本地视图:到期即按非会员对待;登出清 vip 态 + 删 account.json。

20.3 逐级降级 + 下首恢复 + 生效档显示

  • ncm_api_song_url_auto 扩全档表:从 desired 逐档向下(surround→…→standard), url 空/未知 type 继续降;out_level 回填实际命中档;响应 data[0].level 无歧义串 (standard..jymaster)且低于请求档时采信响应档(服务端静默降档如实显示),sky 族 歧义保守取请求档。新增 ncm_api_song_url_auto_f(try_fn 可注入)供离线 sim。
  • 播放路径(player_manager song_local_file):用户档先按 account_level_cap() 钳顶 (NONE→EXHIGH / CVIP→HIRES / SVIP→SURROUND)再降级——超权限档允许选定不硬拒, 但免去注定空 url 的高档请求(风控)。缓存键仍=用户设定档(降级产物归用户档键, 「同设定永不重下」,§19 语义不变)。下一首从用户档重新起步(链内无记忆)。
  • 生效档 g_play.eff_level(mu 保护):下载时由 auto 回填;缓存命中按 ext 推断 (mp3 实体+用户档>极高 → 必为降级产物标 EXHIGH;flac 实体按用户档——档内降级 如 hires→lossless 会高估,已知取舍,真实档位无损音质感官一致)。play/next 重置 -1。
  • 播放页动态带时间行中央小字:生效档=用户档显示档名,不同则红字「已降级至 xx」 (g_last_eff 沿触发刷带)。

20.4 fee 权限门禁(BIZ_NOTES §3 社区口径)

  • song_local_file 在缓存查找之后、song_url 之前:fee∈{1(VIP 才能听),4(付费专)} 且 account_vip()<CVIP → NCM_ERR_VIP(新错误码 -15)直接返回,零 url 请求 (省一次请求,防风控);播放页显示「VIP 专享,开通会员后可播放」+ snack「VIP 专享歌曲」。
  • fee=0/8 放行(8=免费听低音质,播放允许);已缓存的 VIP 曲本地可播不拦 (NCM_CACHE_DESIGN §2.4:下架/权限变化歌曲优先本地缓存)。
  • fee 字段本就在 ncm_song_t 且 songmeta bin 原始镜像序列化,盘缓存 round-trip 无需改格式。

20.5 UI(设置页八档 + 默认档)

  • 设置页音质 sheet 八档(高→低排列),每项「名称 (规格) · CVIP/SVIP(·未解锁)」; 未解锁档允许选定(播放时降级),行 sub 标「当前: xx (规格) · 需 CVIP/SVIP,将降级」。 ⚠️ meow_sheet 无单项置灰能力(SDK 不在本批范围),置灰以「·未解锁」文字标注代替。
  • 默认档 Hi-Res → 极高 HQ(EXHIGH):仅新装/越界值生效,已持久化的合法旧值原样保留。
  • 页脚改「无损/Hi-Res 需 CVIP ・ 臻音/母带需 SVIP,不支持时自动降级」。
  • 档位显示名/规格收敛 chrome.c(ncm_level_name/ncm_level_spec,settings/player 共用)。

20.6 验证记录(0 真实 NCM 请求)

项 方式 结果
vip 解析 9 例 test_ncm_core(离线合成 JSON:v2 SVIP/CVIP/NONE/已过期/仅 expireTime 有效/v1 扁平 root/redVipLevel/code=301) 全 PASS(宿主 + qemu-mipsel)
档位表 level_str/key/gate 断言(sky vs sky_c51 唯一性) PASS
降级链全矩阵 bench/degrade_sim.c(新,auto_f 注入假 try_fn):desired 9 × avail 9 + 全不可用 9 = 90 例全 PASS(命中档=min(desired,avail)、请求数=逐档不差一次);钳制(DOLBY→SURROUND/负数→STANDARD)、未知 type 跳过、type 缺失按扩展名收 flac、下首恢复用户档、服务端静默降档采信 ALL PASS
播放状态机 player_flow_sim P1-P8:P7=VIP 钳顶(非会员用户档 SURROUND→请求档 EXHIGH/缓存键仍 1000_sky_c51/eff 回填;SVIP 直达);P8=fee 门禁(fee=1 非会员 NCM_ERR_VIP 零请求/缓存命中绕过/CVIP 放行/fee=8 放行);P1-P6 零回归 ALL PASS
滑窗/分页/缓存/封面/audiod window_sim / pagination_sim / cache_evict_sim / cover_chain_sim / audiod_chain_sim(重编新 appstate.c/audio_out.c) 全 PASS(14/14 等)
解码回归 test_decode flac 44.1k/16 + 96k/24 + mp3(本批未动 decoder/audio_out,确认零回归) 3/3 PASS
mipsel 构建 build_ncm.sh 自有代码 -Wall -Wextra 0 warning;text 962908(+8.4KB vs §19)
宿主离屏 build_ncm_host.sh + 6 页 MEOW_UI_DUMP 全 rc=0;设置页布局核验(新行 sub/页脚生效)

20.7 md5(本批产物 + 改动文件)

9d31f2241a62f618d7625a24f9f7a3a7  ncm        # mipsel 静态 App(新;未部署,留主会话)
8a0a4ee03509c29ad7eb0ec9f61cd9db  ncm_host   # 宿主离屏调试二进制
2c1c459dbae23872557378477880d7a3  core/ncm_api.c
3d5fb722d5fdf5d9ac1f61e18450b586  core/ncm_api.h
e87f71c90175b67307d92d83d57b8886  core/ncm_common.h   # +NCM_ERR_VIP(-15)
8e0b34b05f0063a4653236577e05cd47  core/ncm.h         # 默认档注释
8bb15b7d9bd98f583761dab2260b98cd  app/logic/account.c      # 新:会员态缓存+刷新策略
3909d070d0290730739456ef0ab1e153  app/logic/appstate.c     # 默认 EXHIGH/account_load/登出清理/level_key
f080c9f4823a5b59410445b5ccc4f191  app/play/player_manager.c # 钳顶+fee 门禁+eff_level
7f94bfbd6ce739a8a52273316ca2eb9d  app/ui/settings.c        # 八档菜单
baa6d5530764258ae9f93b6e4150e06e  app/ui/player.c          # 生效档小字+VIP 专享文案
bf776093956102989a43dfc778efdd01  app/ui/chrome.c          # ncm_level_name/spec
771f351d82dbea46cc1d61c4bfd3bd41  app/ui/login.c           # 登录后 vip 刷新
de6adb873dc426e4973f12866df21eeb  app/ui/home.c            # home worker 超期/到期刷新
6b6816096d1c26773f88a882f30fa65a  app/ncm_app_internal.h   # g_st.vip*/eff_level/声明
9f8a812f48d673514ae4b7145a6689c7  test/test_ncm_core.c     # vip 解析+档位表用例
a5e76601554c8981457ab3a1987dcee2  bench/degrade_sim.c      # 新:降级链 90 例矩阵
27b01f4bfd6199b62f6490367b47bd4b  bench/player_flow_sim.c  # P7/P8 + stub 更新
8af2179da8709b2b525e9744a887de79  build_ncm.sh             # +account.c
ac5b29c40488f34e9220b14105bf63c2  build_ncm_host.sh        # +account.c

20.8 诚实出入 / 遗留

  • 真机未部署未验(留主会话统一)。真机验收点:①登录后/超 1 天/到期三条触发路径 各只发一次 vip/info(日志 account: vip refresh (<why>) -> vip=N)+ account.json 落盘 ②SVIP 档(jyeffect/sky/jymaster/sky_c51)按账号实测可用性逐级降 ③播放页「已降级至 xx」 小字 ④fee=1 非会员曲点播放直接「VIP 专享」且无 song_url 请求 ⑤新装默认档=极高 HQ。
  • sky 两档语义自研分配(臻音全景声=裸 sky / 沉浸环绕声=sky+c51):BIZ_NOTES §2.1 命名 歧义无代码证据可消,真机若实测相反(如裸 sky 才是 5.1),只需对调 settings.c UI 表两项 的档位常量,零结构改动。Audio Vivid 若官方另有独立端点/参数则后续再补。
  • 缓存命中的 flac 实体 eff 档推断可能高估(档内降级不可还原);VIP 档真实可用性以服务端 url 是否为空为准(门槛表只是省请求优化,服务端裁决权威)。
  • vipType(login_status profile.vipType 10/11 口径)未用——BIZ_NOTES §1.2 标注非代码证据, 以 vip/info 双端点字段判定为准;真机若 vip/info 全挂可再加 profile.vipType 兜底。
  • meow_sheet 无单项置灰(SDK 不在范围):未解锁档以「·未解锁」标注代替置灰。

21. 边下边播阶段 1+2:stream_src 流式源 + 全链 PCM 金标准仿真(2026-09-01)

范围:仅 patch_v2/ncm_app/。按 devdocs/plan/NCM_STREAM_PLAY.md 方案 A 实现 (F1-F7 实测事实为红线)。阶段 3(.part 断点续传 / LRU 防淘汰 / 失败重试 1 次) 本批明确不做。风控纪律:真实 NCM 请求 0 次;未部署真机(留主会话)。

21.1 stream_src:只读流式字节源(app/play/stream_src.h/.c,新)

  • 语义:对下载中的缓存 .tmp 文件提供 fread 语义只读视图——只读 fd + pread (不动下载者文件位置),mutex/condvar 同步;状态 = written/final/failed/abort/read_pos。
  • stream_src_read_cb 严格 fread 语义:填满才返回,真 EOF(final 且读到 written) 才短读;100ms timedwait 兜底醒(防 note_written 丢失信号死等)。
  • 再缓冲迟滞:单次调用累计等待 >500ms 后,要求前方可用 ≥64KB 才放行(仅本次调用 尚未产出任何字节时下闸),避免慢网 1 字节一放行的抖动空转。
  • 统计:underruns(按「本 read 调用是否发生过等待」计一次——原 done==0&&avail==0 口径被 minimp3 refill 模式绕过,实测 wait=4s 但 underruns=0,已修)/ wait_ms / stalled(单次等待 >1s 置位,供播放循环联动 audio_out_pause)。
  • dr_flac 适配三回调(read/seek/tell)。SEEK_END 修正:下载中(!final)按 dr_flac 约定返回 FALSE 跳过元数据总长校验;final 后按 final+offset 应答——此前 origin=2 落进 SEEK_CUR 分支导致 drflac_open 恒失败(/tmp/df_dbg.c 实证,修复后 44.1k/96k flac 均开成功)。

21.2 decoder 双路切回调(app/play/decoder.h/.c)

  • 新 ncm_dec_open_src(out, stream_src_t*, ...);ncm_dec_open(path) 改为包一层 静态 stream_src(src_owned=1,dec_close 连带释放)——本地文件路径零行为变化。
  • mp3 = mp3dec_ex_open_cb(MP3D_SEEK_TO_BYTE | MP3D_DO_NOT_SCAN,io 存 decoder 内); flac = drflac_open(stream_src_drflac_read, dec_flac_seek, dec_flac_tell, src, NULL) (枚举与 drflac_int64 签名不兼容 → 静态适配器转换);格式 sniff 改 stream_src_peek。
  • mp3 seek 不再关重开:直接 mp3dec_ex_seek(F4:open_cb 下 SEEK_TO_BYTE 直达, 已下载区 seek 零等待)。

21.3 播放链(app/play/player_manager.c)

  • song_src_t{src, w, dl_thr, dl_rc, url, maxb, total}:缓存未命中 → 起 dl_thread 跑 ncm_http_download(cb:abort 检查 → ncm_cache_write → stream_src_note_written)。
  • 预缓冲:stream_src_wait(128KB, 100ms) 循环(still_current 护栏,切歌即 abort+join+ cache_abort);出口判据 = aborted 或 (failed 且 written<128KB) 才失败——断流但已过 门槛 → 放行(F5:短读即真 EOF,播已下载部分)。
  • run_song 走 ncm_dec_open_src;解码循环 stalled 沿 → audio_out_pause(1)、恢复沿 pause(0)(停 PCM 时钟不丢流);underruns/wait_ms 统计变化才写 g_play。
  • 曲尾 song_src_dispose:abort+join → dl_rc==OK 则 commit+evict,否则 cache_abort。
  • g_play 新增 underruns/underrun_ms/stalled(app/ncm_app_internal.h;play/next 清零)。

21.4 HTTP/缓存底座(core/ncm_http.c/.h、core/ncm_cache.c/.h)

  • P2:30s 绝对 deadline 拆为两段——连接/握手/请求/响应头维持 15s 绝对钳; hdr_done 后启用 body 空闲超时 15s(NCM_HTTP_BODY_IDLE_MS,每 ret>0 顺延), WANT_READ/WRITE 按 hdr_done 二选一——慢速长下载不再被总 deadline 误杀,真挂死仍 15s 断。
  • ncm_cache writer 由 FILE* 改 fd(open(O_WRONLY|O_CREAT|O_TRUNC) + write(2) 循环 直写;commit = fsync+close+rename 原子落地);新增 ncm_cache_writer_tmp_path() 访问器(stream_src 按 .tmp 路径 pread,无需知道 writer 内部)。

21.5 验证记录(0 真实 NCM 请求)

项 方式 结果
边下边播全矩阵 bench/stream_sim.c(新:假网络线程[速率/抖动/cut40/flaky]→真实 ncm_cache→真实 stream_src→真实 decoder→audio_out null sink+PCM 捕获;金标准=同文件离线解码 memcmp):10MB/s、400kbps、160kbps × normal/seek_dl/seek_wait/cut40/flaky + flac_stream@400kbps = 16 用例 宿主 + qemu-mipsel 双平台 ALL PASS;正常/断续 PCM 与整首逐字节一致,断流与 40% 前缀逐字节一致
首开声延迟 同上矩阵实测 10MB/s=0.00s(宿主)/0.13s(qemu);400kbps=2.60s/2.69s;160kbps=6.54s/6.57s
播放状态机 player_flow_sim P1-P8(适配:ncm_dec_open_src stub、ncm_cache_open 真空 .tmp、commit 移曲尾 dispose → P7 半程先 stop 再等 commit 落地防 stub 污染断言) ALL PASS
解码回归 test_decode 三样本(flac44=132300f/44.1k/16、flac96=192000f/96k/32、mp3=393984f/48k/16);另 git show HEAD: 取旧 decoder.c 编 test_decode_old,三样本 WAV 新旧逐字节一致 3/3 PASS + OLD-VS-NEW IDENTICAL
qemu-mipsel 解码 test_decode_mipsel 三样本(重链 +stream_src) 3/3 PASS(112×/35.5×/3.2× RT)
core 回归 build_core.sh → test_ncm_core_host + qemu-mipsel test_ncm_core_mipsel 全 PASS(含 vip 解析/档位表/缓存命名)
链路回归 cache_evict_sim / cover_chain_sim / audiod_chain_sim / degrade_sim / window_sim / pagination_sim / toggle_sim(全部重编新 ncm_cache.c/audio_out.c/appstate.c) 全 PASS(audiod 14/14 等)
mipsel 构建 build_ncm.sh 自有代码 -Wall -Wextra 0 warning;text 969996(+7.1KB vs §20)
宿主离屏 build_ncm_host.sh + home/player/settings MEOW_UI_DUMP 全 rc=0,播放页渲染核验正常(sdk meow_ui_vol.c 既有告警除外)

21.6 ⚠️ 断言口径偏差记录(主会话定夺)

  • 任务原断言「首开声 <2s@400kbps」与 F3 红线物理冲突:mp3 open_cb 强制预读 MINIMP3_IO_SIZE=128KB → 128KB/50KB/s = 2.62s 下限,<2s 不可达。sim 实测 2.60-2.72s。 本批未擅自偏离已批准方案,按可达阈值断言(400kbps <4s,注释写明冲突)。 若要 <2s,选项:MINIMP3_IO_SIZE 减半至 64KB(→~1.31s,重扫 vendor 头文件默认值, 需复测全矩阵);或预缓冲门槛与 IO_SIZE 解耦(首 64KB 即开声,留给 decoder 内部 refill 等)。
  • flaky 用例 underrun>0 由单用例硬断言改跨速率聚合断言:单次断续网是否打空缓冲 取决于解码速度(qemu 慢→缓冲消耗慢→宿主 1 次 qemu 0 次,皆正常),PCM 逐字节一致 才是硬判据;聚合断言(宿主=3/qemu=1)仍覆盖统计通路功能。

21.7 md5(本批产物 + 改动文件)

0dc39a6cf1115fb19644af3cba4c017d  ncm        # mipsel 静态 App(新;未部署,留主会话)
bd95bbaa0fc2ac3a60bce7bb7cc120ab  ncm_host   # 宿主离屏调试二进制
1385361ab09bf4c2ec1db0087451e5c2  app/play/stream_src.c   # 新
1b803c7f9b70ad3df18689143a1b92a8  app/play/stream_src.h   # 新
1b0197c6c4f27675e932024740aaeeab  app/play/decoder.c      # open_cb 双路 + ncm_dec_open_src
7fd0b09abbb2b75ab4c04e31211bedb2  app/play/decoder.h
fda5d4bb09bb26073b34986aa75cceee  app/play/player_manager.c # 下载线程+预缓冲+stall 联动+曲尾 commit
62aeff05b618bc6838b81aadd6c7cebb  app/ncm_app_internal.h    # g_play underruns/underrun_ms/stalled
15c73a3ee5bc07ff2c26dbd26ff5e321  core/ncm_http.c           # P2 空闲超时
b7ed6c165bbc42b36bde43fe634b1cec  core/ncm_http.h
422181883426e1289115bb9ec6bae986  core/ncm_cache.c          # fd writer + tmp_path 访问器
fe78a119bde44d57c4cae2e5b276c646  core/ncm_cache.h
ce7e1ece2577547d54f6c4cc1cf45bdf  bench/stream_sim.c        # 新:全矩阵+PCM 金标准
36abe7cdcf4681e18fed595fbc65435d  bench/player_flow_sim.c   # stub 适配+P7 时序修正
a26350ee298f63e828e06489fee35b92  build_ncm.sh              # +stream_src.c
a80ee35f243ac28379cd1adeb61548ed  build_ncm_host.sh         # +stream_src.c
(test/test_ncm_core_host 重编产物随 build_core.sh 更新)

21.8 诚实出入 / 遗留

  • 真机未部署未验(留主会话统一)。真机验收点:①缓存未命中歌曲 2-3s 内开声 (400kbps 档约 2.6s)②弱网(限速 160-400kbps)播放不卡死、underrun 后自动恢复、 stalled 时暂停不走时钟 ③播放中切歌/退出:下载线程即 abort、无残留 .tmp ④断网半曲 播已下载部分后自然结束、不重复下载 ⑤完整下完的曲下次命中缓存秒开。
  • 阶段 3 未做(方案明确排除):.part 断点续传(断流曲下次仍从头下)、LRU 防淘汰 (下载中的 .tmp 可被 evict 边缘情形)、下载失败重试 1 次(现为 0 重试直接失败)。
  • flac 大元数据块(大封面 PICTURE 块)open 时可能多等下载(drflac 需扫过元数据链才到 首帧);实测 44.1k 向量 400kbps 首开声 3.82s(vs mp3 2.60s)即此因。
  • UI 菊花/缓冲提示未接 g_play.stalled(字段已备,UI 批再做)。
  • 断续网 underrun 聚合断言为时序容忍口径(§21.6),非判据放松——PCM 金标准不变。

22. 播放页重做:封面页/歌词页双页 + MD3 矢量图标 + 点行跳转 + 背景模式(2026-09-01,按 NCM_CACHE_DESIGN §6 定案)

范围:仅 patch_v2/ncm_app/。未动 sdk/(sb_icons.h 只作图标范式参照,未改)、meow_launcherd/、 core/(缓存/解码/网络底座零改动)。风控纪律:本批真实 NCM 请求 0 次(封面/歌词/手势全走 宿主 mocK 与本地样本注入)。部署/真机验收留主会话统一。上一批(§21 边下边播)已 commit 9e25db7, 本批在其上做。

22.1 设计定案(NCM_CACHE_DESIGN §6)与实现映射

定案 实现
去掉现有标题栏(播放页不再用 ncm_topbar) 删除 player.c 的 g_tb/topbar 创建与 MEOW_TOPBAR_BACK 命中;返回 = 左缘手势(SWIPE_LEFT→app_on_swipe→page_pop)/ 系统返回钮语义,app.c 不改
双页结构:封面页/歌词页,屏幕正中左/右滑切换 新增内部页 g_ppage(0=封面、1=歌词);player_set_page/player_page/player_page_go;页面指示点(2 点,当前页红色)
手势分流(左缘≤24px=返回、顶部=通知、中部=页切换) 不抢 SDK 左缘(PLR_EDGE_W=24 与 SDK EDGE_W 一致)与顶部热区(y>=statusbar_hotzone());中部横滑用 on_touch_down/move 锁存 page-drag + on_tap(中部左滑落 TAP)判向 → 切页;中部右滑由 SDK on_swipe(RIGHT) → 切页(左滑/右滑都覆盖)
封面页:方形大图+圆角,无旋转动画;进度条(可点 seek)+控制钮+元信息+实际音质档 meow_ui_img_cover 方形 + round_corners_bg 四角裁圆角;进度条点击 seek;控制钮 = MD3 图标;元信息(歌名/歌手)+ 实际生效档/降级标注
按钮全部用 MD3 矢量图标(32×32 alpha,参照 sb_icons.h 范式) 新增 app/ui/ncm_icons.h(PREV/PLAY/PAUSE/NEXT 四枚 32×32 单通道 alpha,几何近似+8× 超采样抗锯齿,参照 sb_icons.h 存储范式);draw_md3_icon 最近邻缩放 + 逐像素 fill_rect_alpha
歌词页:LRC 滚动(当前行居中高亮、上下渐隐)+ 点行跳转 沿用 lyric.c;新增 lyric_line_time(i)(点行 seek 用);当前行 size=24 红高亮、上下行 size=20 按距中心 mix_c 渐隐;点行 → player_seek(lyric_line_time(i))
歌词页默认=封面模糊置底;省电=封面背景+黑色半透明叠加层(设置 ncm.player_bg_mode) 默认:封面 meow_ui_img_scaled 铺满 + 主题 th->scrim α150;省电:铺满 + 0x000000 α195;设置页新增行「歌词页背景」sheet 二选一,持久化 ncm.player_bg_mode
日夜间:主题配色只映射到半透明叠加层,不动沉浸底 新增 NCM_PLAYER_BG 固定沉浸底;ncm_on_theme 在 ACT_PLAYER 时 page_bg 恒 NCM_PLAYER_BG(不改底),其余页面仍跟随系统明暗;歌词 scrim/accent 随 g_dark/主题变化
缓冲「缓冲中…N%」/错误条(256 那套)沿用 封面页:缓冲文案替时间行、错误文案(VIP/无音源/登录过期/网络)+「点播放键重试」;ncm_snack 错误沿沿用
退出=后台播放(M4 background_audio,退出语义不动) app.json 已声明 background_audio:1;播放线程/退出路径未动

22.2 性能(分带刷新纪律沿用)

  • 封面页拆「封面静态带 g_cover_r(z8)」+「动态带 g_dyn_r(z9)」两个绘制物,bbox 互不重叠: tick 只标脏动态带(进度/控制/元信息/指示点);封面带仅 歌曲/封面 变化(player_static_invalidate) 才重画 → 不整页重绘。
  • 歌词页为整屏带状物:仅在 进入/切歌/封面变化/换背景模式/换行(~5s 一次,歌词按行进非逐帧) 时整屏重画;pos tick 不重画歌词页(g_last_line 沿驱动,行间静止)。
  • 模糊底图 = 封面按屏最近邻铺底 + scrim;区域重画时 SDK 绘制 clip 收敛(meow_ui_draw.c, g_clip_stack),只算相交区,不整屏 overdraw(§5.8 记忆点)。
  • 控制钮由「逐帧行扫三角」改为 MD3 图标位图 blit(扫描 + 每像素 blend565),code 更简单、观感对齐 Material。

22.3 验证记录(0 真实 NCM 请求)

层 手段 结果
mipsel 交叉 bash build_ncm.sh(自有代码 -Wall -Wextra) 0 warning;text 963042 / data 1608 / bss 1113988(bss 增量仅来自 icons/ncm 段,均常量)
宿主离屏 build_ncm_host.sh + MEOW_UI_DUMP(mock,0 网络) 自有代码 0 warning(仅 SDK meow_ui_vol.c 既有 system() 告警)
四页 PPM 核验 NCM_PAGE=plcover/pllyric × NCM_THEME=light/dark + NCM_COVER=本地样本注入 4 页全 rc=0:封面页=方形圆角封面+歌名/歌手+时间+红进度条(52%)+prev/暂停/next MD3 图标+指示点;歌词页=封面模糊置底+顶部「歌名·歌手」+当前行红高亮居中+上下渐隐+指示点;日/暗两套底恒 NCM_PLAYER_BG(不动沉浸底)、accent/scrim 随主题
缓冲/错误态 NCM_PAGE=playerbuf/playererr 全 rc=0:缓冲=「缓冲中… N%」文案+空进度条+暂停钮;错误=错误文案+「重试/切下一首」+播放钮(STOPPED→play 重试)
省电背景 NCM_PAGE=pllyric NCM_BGMODE=power_save 全 rc=0:封面背景更暗(0x000000 α195),与 default 明显区分
手势切换自检 NCM_PAGE=gestest(宿主直调 touch_down/move/on_tap/on_swipe) PASS:p0=0 → 中部左滑=1(歌词) → 中部右滑=0(封面);进度条上按下拖拽不锁存(不被误判翻页)
qemu core 回归 timeout 120 qemu-mipsel ./out/test_ncm_core_mipsel RESULT: PASS(core 全量,本批未动 core)
宿主 core test/test_ncm_core_host PASS
设置页 NCM_PAGE=settings rc=0,6 行(新增「歌词页背景」行存在)

22.4 md5(本批产物 + 改动文件)

730e0b6eaa0b3f626f828bbaa51dc5a8  ncm                       # mipsel 静态 App(新)
500ac859618a58bc2a641553a383a10f  ncm_host                  # 宿主离屏调试二进制(新)
00934dec4452f2f31e6f473c64bd311f  app/ui/player.c
20f0bbac74099d07638d4f92a340eb82  app/ui/ncm_icons.h        # 新:MD3 prev/play/pause/next 32x32 alpha
aedd5ff24e227eeda94341e63eac4064  app/ui/settings.c
31ae666b0f7497bb3c3391da9ce4636a  app/ui/ncm_colors.c
b1c8f029886a60288e96bcf54476d7bf  app/logic/lyric.c
63127b670604ff67dd176224088b16c4  app/logic/cover.c
aa5a5f22e10662c2247ece166c13ea6e  app/logic/appstate.c
e2a7062b0033234649fe03767a4c009d  app/app.c
fa00ea4f0093cd7e0a2600887106de07  app/ncm_app_internal.h
2c484e5e920c0b05c6da107d15fd8a6f  app/test/test_ncm_app.c

22.5 出入清单(只改 patch_v2/ncm_app/)

修改:
  app/ui/player.c           # 播放页重做:去 topbar + 封面/歌词双页(中滑切换) + MD3 图标控制钮 +
                            #   方盖圆角 + 点行 seek + 封面模糊/省电背景 + 分带刷新 + 手势分流
  app/ui/ncm_icons.h        # 新增:MD3 prev/play/pause/next 32x32 单通道 alpha 位图(参照 sb_icons.h)
  app/ui/settings.c         # 新增「歌词页背景」行(default/省电 sheet 二选一)
  app/ui/ncm_colors.c       # ncm_on_theme:ACT_PLAYER 沉浸底恒 NCM_PLAYER_BG(不动沉浸底)
  app/logic/lyric.c         # 新增 lyric_line_time(i):点行跳转
  app/logic/cover.c         # 宿主测试桩 cover_host_inject(#ifdef MEOW_UI_HOST_TEST,产品不编)
  app/logic/appstate.c      # 设置持久化 ncm.player_bg_mode(load/save + 越界回落 default)
  app/app.c                 # on_touch_down/move 播发页路由(中部横滑页切换跟踪)
  app/ncm_app_internal.h    # NCM_PLAYER_BG/NCM_BG_* + player_bg_mode 字段 + player_set_page/
                            #   player_page/player_touch_* + lyric_line_time 声明
  app/test/test_ncm_app.c   # 宿主:plcover/pllyric/gestest 模式 + NCM_BGMODE/NCM_COVER 注入
  ncm / ncm_host            # 重编产物
(未动 sdk/、meow_launcherd/、vendor/、core/;REPORT.md 本批记录 §22)

22.6 诚实出入 / 已知限制

  • 真机未部署未验(留主会话统一)。真机验收点:①双页中滑流畅(跟手感/指示点)②点行 seek 命中时间 ③封面模糊置底观感(默认)vs 省电黑色叠加(对比明显)④设置页「歌词页背景」持久化、 重启生效 ⑤返回=左缘右滑仍正确(不误触页切换)⑥播放中退出=后台播放(M4,未动)。
  • 歌词页「当前行居中」为行进式(按 LRC 行切换,非逐帧平滑滚动):这是 MIPS 软帧下 「流畅性廉价可靠」取舍(§11);当前行高亮 + 上下行渐隐已提供视觉焦点。
  • 封面模糊 = 封面按屏最近邻铺底 + scrim(廉价近似):无 GPU 模糊/降采样原语,非真高斯模糊; 非均匀铺底(480×720)会轻微拉伸封面(居中裁方可解但需另建 thumbnail 缓冲,本批不做)。
  • 歌词页顶部元信息/整屏重在换行时重画:换行频率 ≈ 每行(数秒)一次,非逐帧;pos tick 不刷 歌词页(只在其换行时刷),故 MIPS 上歌词页代价可忽略。
  • 页面对比色固定浅色系(播放页为沉浸深色介质,文本若随主题 onSurface 在浅色主题下为近黑会 隐形):这是「不动沉浸底」定案的必然——播放页文本/控件用固定浅色,仅 accent(红)/scrim 随日夜间。
  • player_set_screen 保留为兼容占位(恒竖屏):重做后播放页为竖屏双页(旧「横屏布局」随顶栏 ⟳ 钮一并退役);player_screen() 恒 0,宿主 grid/lands 旧模式不再有横屏布局。
  • 歌词读锁残余(既有):lyric_line_text/tl 解锁后返回静态指针可能被下次 lyric_parse 覆盖 (§14.7 已知);本批未改,点行跳转读 lyric_line_time 同理(写入仅在切歌瞬间,窗口极小)。


23. 搜索增强:历史 + 单曲/歌单分类 + 分页 + 误触修复 + 不覆写歌单上下文(2026-09-01,agent-273)

范围:仅 patch_v2/ncm_app/。定案见 devdocs/plan/NCM_CACHE_DESIGN.md §5(历史+自动聚焦+「单曲/歌单」两类起步) 与 NCM_UI_REVIEW.md §2.4/§3-2(搜索缺什么 / 点行误触)。风控:本批 0 次真实 NCM 请求(全离线 stub/ 宿主 mock/离线样本);真机部署留主会话统一。上一批(播放页重做 §22)已 commit,本批在其上做。

23.1 搜索历史(进页自动弹键盘 + 本地持久化)

  • 进页自动弹键盘:search_open/sr_page_enter 后若 g_field && search_query 为空 → meow_textfield_activate。 #ifndef MEOW_UI_HOST_TEST 包住(宿主离屏为阻塞式模态 IME,避免挂起 dump;真机走阻塞式弹键盘——与既有 「点输入框唤 IME」同构)。
  • 持久化:appstate.c 新增历史函数族(appstate_search_history_add/get/count/remove/clear),内存数组 g_hist[12][128](静态,bss,最小)+ 单键 meow_config(ncm.search_history,条目以 \n 连接)。 语义:去重 + 最新在前 + 截断 ≤12(用户定案)。search_submit 成功提交即 add(kw)。
  • 空态=历史列表:sr_view() 在 search_query 为空且历史非空 → SR_HIST:列表行=历史(点=直接 search_submit)、 右缘「清空全部」按钮、左滑删单条(search_swipe_left,见 23.4)。历史为空回落「输入关键词」空态。

23.2 结果分类「单曲/歌单」+ 独立页签

  • 顶部小页签(r_tabs[0/1],各 r_btn.w/2-6):单曲(type=1)/歌单(type=1000)。选中=品牌红下划线高亮。
  • 两页签各自独立结果集与滚动位置:g_st.search_songs[](单曲,NCM_SEARCH_SONG_MAX=256)与 g_st.search_pls[](歌单,NCM_SEARCH_PL_MAX=60)互不干扰;滚动各存 g_scroll_song/g_scroll_pl, sr_set_tab 先存旧再恢复新。非当前页签在提交新查询时被清空(g_tab^1),切过去懒加载(用户动作)。
  • 单曲行点击 → player_play_search_song(i)(搜索队列起播);歌单行点击 → playlist_open_pl(&g_st.search_pls[i]) (进该歌单详情,见 23.5)。

23.3 加载更多(触底分页)

  • appstate_search_count(tab) 即该页签已加载条数(=下次 offset);appstate_search_has_more(tab) 判还有下一页。
  • 触底(scroll + r_list.h >= count*itemH - itemH*2)且 more & !g_thr_on → search_fetch(tab, offset=count, 0)。 cloudsearch 同参数 limit=NCM_SEARCH_PAGE(30)、offset 累加;每类各记各的 offset(search_song_offset/ search_pl_offset,appstate append 回填)。
  • has_more 判定:total>=0 ? (count<total) : (本页==limit);结果数组到上限仍按服务端总数反映(UI 层 n>=cap 停止拉取,内存护栏独立于服务端 total)。

23.4 误触抑制(NCM_UI_REVIEW §3-2)

  • search_on_tap 点行套用 §13.3 修法(对齐 home/playlist):was_drag = (g_press_delta > 10); row = (g_press_row >= 0) ? g_press_row : meow_listview_hit(...);row<0 || was_drag 不点行。 g_press_row/g_press_delta 在 search_touch_down/move 已记录(沿用),历史模式同样生效。

23.5 结果不覆写歌单上下文(核心解耦)

  • 搜索结果改独立数组:search_worker 不再写 g_st.songs[] 也不再 appstate_adopt_songs;单曲结果写 g_st.search_songs[](appstate 持 g_search_mu,经 appstate_search_append_songs 提交),g_st.songs[]/ song_ids/track_ids_count(歌单窗口)永不被搜索触碰。
  • 播放搜索单曲 = 建立「搜索队列」上下文:player_play_search_song(i) 设 g_st.queue_src=NCM_QUEUE_SEARCH → appstate_queue_count()/queue_song() 改读 search_songs[](player_manager.c 的 run_song/next/player_thread/lyric_worker 全部改走 appstate_queue_*,按 queue_src 分派窗口或搜索)。 player_manager_next/player_thread 边界 = appstate_queue_count()。从歌单详情退到搜索页时原歌单窗口 不丢(track_ids_count/song_ids 未被改写)。
  • 歌单结果进详情:新增 playlist_open_pl(const ncm_playlist_t*)——拷入 g_st.search_pl_open、 active_playlist=NCM_PL_SEARCH 哨兵;appstate_load_playlist_songs_page/appstate_apply_playlist_cover/ pl_cur(playlist.c)对哨兵改读 search_pl_open,复用既有滑窗/封面/分页链路,不污染真实歌单列表。

23.6 core:cloudsearch 扩展(type/offset/limit/total)

  • ncm_api_cloudsearch_song(type=1)/ncm_api_cloudsearch_playlist(type=1000):共用 cloudsearch_post POST(s/type/offset/limit/total:true)+ cloudsearch_count (读 result.songCount/result.playlistCount,缺省 -1)。新增 parse_playlists_array(id/name/coverImgUrl/ trackCount)。旧 ncm_api_cloudsearch 保留为单曲 wrapper(type=1, offset=0)。

23.7 验证记录(0 真实 NCM 请求)

项 方式 结果
历史去重/截断/最新在前 bench/search_sim.c(离线,驱动真实 appstate 历史函数) PASS:去重 2 条;重add 最新在前;截断 ≤12(kw16 在顶、kw8 在 idx8 顺序保留);remove 前移;clear
单曲/歌单独立结果集 bench/search_sim.c PASS:song 集 4/pl 集 2 互不干扰;clear song 留 pl;song/pl copy 正确
翻页 offset/has_more bench/search_sim.c PASS:clear 复位 offset=0/more=1;page1 30→offset 30;累计到 total 240→more=0;clamp 至 SONG_MAX 且 total>cap→more=1;无 total(-1)回落页大小判 more
搜索队列上下文(不覆写窗口) bench/search_sim.c PASS:queue=playlist 7/queue=search 3;queue_song 读搜索集;track_ids_count/song_ids/song_valid 原样
误触抑制 app/test/test_ncm_app.c searchtap(真实页面+真实 SDK 手势) PASS:干净点击→播按下行(cur=0,qsrc=SEARCH);拖动(+30px>10)→was_drag 抑制(cur -1→-1)
core 回归 test_ncm_core_host / qemu ./out/test_ncm_core_mipsel PASS
mipsel 构建 bash build_ncm.sh(自有代码 -Wall -Wextra) 0 warning;text 972518 / data 1608 / bss 1364548(bss +~1MB 来自 search_songs/pls 独立数组)
宿主离屏渲染 build_ncm_host.sh + MEOW_UI_DUMP(mock,0 网络) 空态=历史列表(searchhist 3 行+清空区)/结果态两类页签(search 单曲 3 行下划线在左、searchpl 歌单 2 行下划线在右)均正常,无崩溃

23.8 md5(本批改动文件 + 产物)

30b24372ab9833c0c49f97be3b9418ee  ncm                       # mipsel 静态 App(新)
ca3b451c0be5e5ead7f215cb8d431594  ncm_host                  # 宿主离屏调试二进制(重编)
b22cc6f5696d778c1a93adcf39ed4847  core/ncm_api.c
14ddc89bf1e1b20afc3c7008377ba928  core/ncm_api.h
330f131d42b9f70ab8792ace2f1a7197  app/ncm_app_internal.h
1e02f90fdd9aae0b3e725548874b19f9  app/logic/appstate.c
21a80495141cec6506e9d5197e56c937  app/ui/search.c
666e18381a6b284b6bb4b1b92c056fec  app/ui/player.c
63485f230aca0f20d7bf0a977b36000c  app/ui/playlist.c
cd0d99b31f56df698c44e3fbeace95fc  app/play/player_manager.c
46d9a5f16e257709010fba79ffe44a61  app/app.c
060bc58975f6f56f6e2a6e87c23b3a1e  app/test/test_ncm_app.c
c9c115cda1340b92d1988e330eafbb5b  bench/search_sim.c

23.9 出入清单(只改 patch_v2/ncm_app/)

修改:
  core/ncm_api.c/.h       # cloudsearch 扩展:type/offset/limit/total + 歌单解析 + parse_playlists_array
  app/ncm_app_internal.h  # 搜索常量/状态字段/search_pl_open/queue_src + 新函数原型 + (去 NCM_SEARCH_MAX)
  app/logic/appstate.c    # 搜索历史(≤12去重) + 独立结果集(append/clear/count/offset/has_more) +
                          #   播放队列来源(queue_*) + NCM_PL_SEARCH 哨兵支持 + 默认 queue_src=PLAYLIST
  app/ui/search.c         # 重写:历史(点搜/左滑删/清空) + 单曲/歌单页签 + 触底分页 + 误触抑制 +
                          #   进页自动弹键盘 + 结果改走 appstate 独立数组
  app/ui/player.c         # player_play_song(队列=PLAYLIST) + player_play_search_song(队列=SEARCH) +
                          #   cur_song() 按 appstate_queue_count
  app/ui/playlist.c       # pl_cur() 助手 + playlist_open_pl(搜索歌单) + 封面/标题哨兵处理
  app/play/player_manager.c # 播放循环/next/歌词 worker 改走 appstate_queue_count/queue_song
  app/app.c               # LEFT 左滑先发 search_swipe_left(历史删单条),未消费才返
  app/test/test_ncm_app.c # 宿主:search/searchhist/searchpl/searchtap 场景 + mock 改独立数组
新增:
  bench/search_sim.c      # 搜索数据层离线回归(历史/独立集/翻页 offset/队列上下文;0 网络)
  REPORT.md               # 本批记录(§23)
(未动 sdk/、meow_launcherd/、vendor/;未 commit/push/刷机/动设备系统/恢复区)

23.10 诚实出入 / 已知限制

  • 真机未部署未验(留主会话统一)。真机验收点:①进搜索页自动弹键盘(拼音 IME 候选上屏)②输入关键词 →点「搜索」出单曲、切「歌单」页签出歌单、滚动触底翻页 ③点搜索单曲播放、上/下一首在搜索结果集内循环、 返回歌单详情原歌单不丢 ④空态历史列表:点=直接搜、左滑删单条、右缘清空全部 ⑤快速横滑列表抬手不误播。
  • 歌单结果进详情走 NCM_PL_SEARCH 哨兵(active_playlist=-2):极少数「歌单列表恰好满 120 且正在浏览」的 场景下此哨兵不与真实下标冲突(哨兵为负,永不在 [0,120) 内);pl_cur() 统一分派,未污染的边界已由 bench [4] 的「窗口原样」校验覆盖。
  • appstate_adopt_songs 现为不可达死代码(搜索结果不再收编窗口):保留(公开函数,-Wall -Wextra 不告警), 供既有单测/文档引用;后续可清理。
  • 历史持久化单键 ncm.search_history(条目以 \n 连接):搜索词不含换行(IME 不产生);若将来支持 粘贴含换行文本需改定界(本项目无粘贴,当前安全)。
  • 两页签懒加载:提交查询只拉当前页签;切到另一页签(其结果为空)才发该页签请求——属用户动作 (点页签),但严格讲不是「点搜索钮」;已避免进页自动搜/逐键发。风控更严可直接在 search_submit 双拉。
  • meow_textfield_activate 为阻塞式模态 IME(SDK 既定):进页自动弹键盘会阻塞 UI 直至用户关闭键盘, 与「点输入框唤 IME」一致(既有交互模式);宿主离屏以 #ifndef MEOW_UI_HOST_TEST 跳过以免挂起 dump。
  • 未动 sdk/、meow_launcherd/、vendor/、core(除 cloudsearch 扩展);部署链路同 §13.5 留主会话。

24. 边下边播阶段 3:.part 断点续传 + LRU 防淘汰 + 失败重试 1 次(2026-09-01)

范围:仅 patch_v2/ncm_app/。按 devdocs/plan/NCM_STREAM_PLAY.md §8 阶段3 任务书实现 (§5.1 LRU 防淘汰、§5.2 .part 续传、§4.1/§7 重试风控)。阶段 1+2 在 §21(commit 9e25db7)。 风控纪律:真实 NCM 请求 0 次(全离线 stub/样本);未部署真机(留主会话统一)。 对外文档(APPS.md/README 等)称「流媒体客户端」,不写「网易云/NetEase」。

24.1 三件事

  1. .part 断点续传(§5.2):下载中断(切歌/断流/失败)后 .tmp 保留为 song_<id>.<ext>.part; 下次播放同曲走 Range: bytes=<part_size>- 一次续传:206=追加、200=服务器忽略 Range(body 头段与 .part 前缀重复,丢弃)、416/其它=放弃续传(删 .part)整首重下。URL 每次新拉(签名会过期, 风控红线「一首一 GET」仍满足)。
  2. LRU 防淘汰(§5.1):ncm_cache_evict/ncm_cache_total_bytes 扫描跳过 .tmp/.part ——写穿缓存态的 .tmp(边下边播全程都在播)与中断残体 .part 归写者管理域,不被缓存上限挤掉。
  3. 下载失败重试(§4.1/§7):预缓冲(<128KB)前网络失败 → 整首重试 1 次(STREAM_RETRY_MAX=1), 重试前退避 800ms(STREAM_RETRY_BACKOFF_MS)防连环轰炸;重试每次重新 song_url(新签名)。 硬错误/切歌/退出不重试(安静放弃)。开播后失败不自动重连(播到截断点,§4.1)。

24.2 改动文件

  • core/ncm_cache.c/.h:is_partial_name(evict/total 跳过 .tmp/.part);ncm_cache_part_size /ncm_cache_part_remove/ncm_cache_open_resume(O_APPEND 追加 .part)/ncm_cache_abort_part (.tmp→.part 保留);ncm_cache_abort 语义不变(封面/测试仍用)。
  • core/ncm_http.c/.h:ncm_http_download_status(parse_headers 后、首 body 前把 resp->status 写入 *status_out,供续传回调预知 200/206/416)+ 放行 206。
  • app/play/stream_src.c/.h:stream_src_open_stream_at(out,path,written)——续传时读端 written 置为 .part 前缀大小、read_pos=0,前缀秒读(F4/F5 语义复用)。
  • app/play/player_manager.c:song_src_t 增续传字段(resume_base/first_body/st_ptr/need_fresh/skip); stream_dl_cb 首 body 按 status 切写姿态;dl_thread 带 Range 头 + 状态感知下载; song_download_attempt(单次下载尝试:.part 检测+续传/整首、预缓冲、失败分类)+ song_local_file 重试循环(≤1 次、退避 800ms);song_src_dispose 中断保留 .part、拒绝续传(need_fresh)删残体。

24.3 验证记录(0 真实 NCM 请求)

项 方式 结果
.part 续传 PCM 金标准 stream_sim 增 resume 用例(会话1 中断 40% 存 .part → 会话2 续传:读端从头跟播、commit 后完整==源)@10MB/s+@400kbps 宿主 + qemu-mipsel ALL PASS:续传 PCM 与整首离线解码逐字节一致(1575936B)、commit==源(328320B)、.part size 正确(131328)、first_sound=0.00s(前缀秒开)
边下边播全矩阵回归 stream_sim 原 16 用例(normal/seek_dl/seek_wait/cut40/flaky × 3 速率 + flac_stream) 宿主 + qemu 全 PASS(PCM 金标准不变)
LRU 防淘汰 cache_evict_sim 新 case7(塞爆池 + 在播 .tmp/残体 .part) PASS:.tmp/.part 存活、不计入 total、最旧正式文件被淘汰
续传 API 契约 cache_evict_sim 新 case8(part_size/open_resume 追加/commit 转正/abort_part 保留/abort 删除/part_remove) PASS(0 警告)
续传控制流(Range/状态分支/重试) player_flow_sim 新 P9/P10/P11:P9=Range 头 + 206 追加 commit;P10=416→删 .part 整首重下(重试无 Range);P11=预缓冲前网络失败→退避序列+重试 1 次后停播 宿主 + qemu ALL PASS(P1-P11;P11 实测 2nd download +862ms ≥700ms 退避)
core 回归 build_core.sh → test_ncm_core_host + qemu test_ncm_core_mipsel PASS(缓存命名/字典/会员态)
mipsel 构建 build_ncm.sh 自有代码 -Wall -Wextra 0 warning;text 975234(+5.2KB vs §21 的 969996)
宿主离屏 build_ncm_host.sh + NCM_PAGE=home MEOW_UI_DUMP rc=0,渲染正常(sdk meow_ui_vol.c 既有告警除外)

24.4 本批顺手修(预存 bug,诚实记录)

  • gb_to_bytes 返回 int 溢出:10/100GB(10×2^30=10737418240)超 int 上限 → 默认 10GB 上限实际 溢出成 0/巨大值,ncm_cache_evict 永不触发(缓存上限设置失效)。改 uint64_t 修真——这是阶段3 「被缓存上限挤掉在播文件」语义能成立的前提(上限真生效,靠 .tmp/.part 跳过实现防淘汰)。 cache_evict_sim case7/8 已复盖;test_ncm_core/cache_evict_sim 旧用例零回归。
  • player_flow_sim P7 SVIP 子用例遗漏 g_eof=0(上一步设了 1 让 song0 收尾,SVIP song1 未重置 → 首帧 EOF、PLAYING 窗口过短)——本批修正(测试自身 bug,不影响生产代码)。

24.5 md5(本批产物)

f8f316a1e6f3bdd8f6b5921fdbb3f3a2  ncm        # mipsel 静态 App(新;未部署,留主会话)
9efb76ff0ae6262eb25870549d0f06d3  ncm_host   # 宿主离屏调试二进制
4b1045011c865bdfd88f447bb08007d9  core/ncm_cache.c    # 阶段3:evict/total 跳 .tmp/.part + 续传 API
50c05a3e472c2309d1f164f9a5102cda  core/ncm_cache.h
649efeddd2908574a6f51661f4abad4e  core/ncm_http.c     # ncm_http_download_status + 206 放行
87a89a0ddac2a41d96fd9fb150136c2d  core/ncm_http.h
84042878770f47e47e0ee78e9e765bb0  app/play/stream_src.c  # stream_src_open_stream_at(续传 written)
1430c2e1f7df36011dc562c3d636176f  app/play/stream_src.h
d47b6f85c53f986bd7339f3f1df1fec5  app/play/player_manager.c  # .part 检测+Range+206/200/416+重试 1 次
21904c4525b94776af02f7ecefde48e2  bench/stream_sim.c   # resume 用例(中断→续传→PCM 金标准)
7ca024b7212aef43d3359cac2cbd014c  bench/cache_evict_sim.c # case7/8:防淘汰 + 续传 API 契约
21348d4df94f7e88815423b32320b9e8  bench/player_flow_sim.c # P9/P10/P11:Range/416/重试退避
e767bb75b7f7ec096b9c9dc01791691d  REPORT.md            # 本批记录(§24)
(build_ncm.sh/build_ncm_host.sh 未改;app.json 对外名按约定改「网易云音乐」→「流媒体客户端」)

24.6 诚实出入 / 遗留

  • 真机未部署未验(留主会话统一)。真机验收点:①断网一场中断 → 重播同曲秒开且续传(不重下前缀) ②播放中下载其他/封面触发 evict,在播首不中断 ③弱网预缓冲失败一次 → 自动整首重试(有 800ms 间隙, 不连环请求)④.part 残体在下次命中秒开(前缀已在盘)。
  • .part 残体若长时间不续传(用户不再点该曲)会占磁盘:残体不进 LRU 淘汰(防在播误删), v1 不做「孤儿 .part 定时清理」,留主会话决断(可加「清理缓存」时一并删)。
  • 200(服务器忽略 Range)分支按「丢 body 头段」实现(与 .part 前缀重复字节丢弃)——因服务器返回全量 时 body[0..part_size] 与 .part 前缀逐字节一致(同URL同内容),续传不产生双份;416 分支静默降级 (播到已下前缀/删残体重下),未做「自动断点续传防抖重连」(风控,§5.2 明确不做)。
  • 重试仅覆盖「预缓冲(<128KB)前网络失败」;开播后失败不重连(§4.1)。STREAM_RETRY_BACKOFF_MS=800 为固定值,未做指数退避多档(retry=1 时一档即可)。
  • 未动 sdk/、meow_launcherd/、vendor/;部署链路同 §13.5 留主会话。