Files
MeowOS-For-R3P2/patch_v2/ncm_app/REPORT.md
T
MeowOS 997f89f879 ncm 三期三批: 歌曲对象瘦身(1324→608B)+滑动窗口(NCM_SONG_WINDOW=256, 取消1000截断支撑任意长度歌单)+播放快照解耦 — agent-251
- core/ncm_api.h: ncm_song_t 瘦身 id24/name128/art96/al96/pic256/dt int32 (1324B→608B)
- appstate.c 滑动窗口: song_ids 全量 trackIds 索引(16384) + songs[256] 窗口感知
  (song_valid 位图/window_base/滑窗 memmove 保留重叠), ensure_range 批网格(100)对齐
  预取, songmeta.c 盘缓存 <cache_dir>/meta/<id>.bin (回滚/二次进入 0 网络请求)
- playlist.c 触底分页退役→视口 ensure(±24 行), 占位行可直点(播放线程 fetch 物化),
  footer 改视口区间/总数
- 播放兼容: g_play.play_song 快照(mu), run_song 起播物化后全链路(下载/歌词/IPC META/
  播放页 UI)读快照——窗口滑出当前播放曲不崩; 边界 song_count→track_ids_count
- 验证(0 真实请求): pagination_sim 全 PASS(5191 无截断/52 批); window_sim 6000 首
  全程下滚 750 步视口全物化/song_detail 恰 60 次/回滚+0 请求/播放 fetch 不滑窗;
  core 宿主+qemu 全 PASS; mipsel -Wall -Wextra 0 warning, bss 2052068→1080900(-47%)
- 未部署真机(设备音频线排查中, 部署留主会话)
2026-09-01 04:03:01 +08:00

1457 lines
119 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 无)。