Files
2026-10-03 00:01:12 +08:00
..
2026-10-03 00:01:12 +08:00
2026-10-02 21:34:56 +08:00
2026-10-03 00:01:12 +08:00
2026-10-03 00:01:12 +08:00
2026-10-02 21:34:56 +08:00
2026-10-03 00:01:12 +08:00
2026-10-03 00:01:12 +08:00
2026-10-02 21:34:56 +08:00
2026-10-02 21:34:56 +08:00
2026-10-03 00:01:12 +08:00
2026-10-02 21:34:56 +08:00
2026-10-02 21:34:56 +08:00
2026-10-02 21:34:56 +08:00

S4PAnim —— 把 S4 eSIM 的压力检测动画换成 S5 41mm 的

一句话:用 Lua 表盘把 S4 eSIM(o62lte)/dev/app 里压力 App 的 119 帧检测动画 等长覆盖成 S5 41mm(q63 / OS4)那套 旋转渐变球。

两份盘,各 ~16 MB,共用一个 watchface id —— 装其中一份就顶掉另一份,设备上始终只占一份空间:

文件 干什么 体积 / sha256
dist/S4PAnim.face 只负责替换:把 q63 的 119 帧全部写进 119 个槽位 16,686,680 B / ce73d9ede1c9a574…(v8)
dist/S4PAnimRestore.face 只负责还原:把原厂字节写回同样的槽位 16,686,680 B / d16b5cfda35089b9…(v8)

两份都是 id 362150102、b5=0。装完一份想换另一份,直接装另一份 .face 即可(同 id 互相顶替)。

为什么要拆

合在一份里 = 负载 15.5 MB + 回滚 15.5 MB = 31.4 MB,而原厂最大的 resource.bin 才 9.4 MB。 拆开后每份只有 15.9 MB,且因为 id 相同,设备上不会同时存在两份。

拆了以后版本校验没丢:每份盘里额外带一张 117×64 B 的指纹表(7.5 KB,就是另一侧字节的前 64 字节), 写入前照样能判断「设备上现在是原厂还是改后」,写入后照样整段回读比对。

源 / 目标

分区 帧 格式 合计
目标 S4 eSIM(o62lte) inputs/target/vela_app.bin(/dev/app) pressure/measure/Measuring1..119.bin 464×464 I8 RLE 15.5 MB
源 S5 41mm(q63 v4.101.020) inputs/source/vela_app.bin pressure/measure/Measuring1..100.bin 480×480 I8 RLE 17.8 MB

两代动画完全不是一套东西:eSIM 是「青绿气泡群脉冲」(119 帧一次呼吸),q63 是「蓝绿渐变球 + 游走高光」(100 帧一循环)。

怎么做到等长覆盖

ROMFS 里每个文件的数据长度固定(后面所有文件的偏移都是绝对值),所以槽位必须逐字节等长替换; 而两边尺寸不同、还都是 RLE,压缩后长度各不相同。三步:

  1. 缩放 + 量化:q63 帧 → 464×464,按 (颜色数, 缩放比例) 阶梯挑一档使编码 ≤ 槽位 csize; 先把接近黑的背景(亮度 <40)钉成纯黑 —— 既干净又大幅缩短 RLE(91/116 帧因此能上 256 色,压平前只有 53 帧)
  2. 编码:LVGL RLE 语法(tools/lvgl_bin.py: rle_compress)
  3. 补齐:pad_rle_safe 用两类「产出 0 像素」的合法指令把流补到 csize (00 00 = 重复 0 次;80 = 原样 0 个,用来修奇偶)—— 原厂自己也是这么补的(stock 流尾部就是一片 00 00)

本机实测

指标 replace 盘 restore 盘
记录数 117(116 数据 + 1 canary) 120(119 数据 + 1 canary)
等长 116 条全部 == 原槽位长度 119 条全部 == 原槽位长度
可解码 116/116 解出 464×464 119/119
字节 与改后镜像逐字节一致 与原厂镜像逐字节一致
指纹 117 条 64 B,= 原厂前 64 B 120 条 = 改后前 64 B
ROMFS inode 表 / superblock / 条目数 / 分区长度逐位不变 同左
独立校验 tools/verify_anim.py replace 11/11 通过 tools/verify_anim.py restore 11/11 通过

替换质量:256 色 91 帧、192 色 1、128 色 1、96 色 2、64 色 1、32 色 5、24 色 3、16 色 5、12 色 3、8/6/3/2 各 1; 最末尾 3 个槽位预算太小(4.5 KB 级)保留原厂。见 docs/anim_before_after.png 与 docs/anim_new.mp4。

重建

python tools/make_anim_payload.py            # q63 帧 -> 等长槽位 + 改后镜像(自带 inode / 回放自证)
python tools/make_dd_payload.py replace      # 负载 + 指纹 + blob_replace/
python tools/gen_ui_lua.py replace           # 两遍构建 -> dist/S4PAnim.face(0 漂移校验)
python tools/verify_anim.py replace          # 独立校验
python tools/make_dd_payload.py restore      # 原厂字节 + 指纹 + blob_restore/
python tools/gen_ui_lua.py restore           # -> dist/S4PAnimRestore.face
python tools/verify_anim.py restore

inputs/、vendor/(LuaDevTemplate,含 Compiler.exe)要自己放,已被 gitignore; payload*/、blob_*/、.work/ 是可再生产物,同样不进仓库。

真机流程

  1. 装 dist/S4PAnim.face → 表盘「环境预检查」(只读)→「开始替换」→ 等 失败 0 → 「重启手表」
  2. 打开压力 App → 开始检测 → 应该是 q63 的旋转渐变球
  3. 想还原:装 dist/S4PAnimRestore.face(同 id,会顶掉替换盘)→ 表盘「开始还原」→ 重启
  4. 表盘里的「回滚修改 / 恢复修改」按钮会提示去装另一份盘(本盘不含另一侧的完整数据)

命令行路线:flash.sh(替换)/ restore.sh(还原)+ 对应 payload*/ 目录,用法见脚本头部注释。

真机反馈后的 v2(已修)

第一次真机跑到「写入 56/117 失败 1」。根因是搬运方式太慢:119 个槽位里有 97 条的字节数不是 4 的倍数 (RLE 流长度随机,如 4485 / 196005),老写法只能退化成 bs=1 —— 一条 19.6 万字节的记录要 38 万次单字节读写, 慢到偶发超时/读回不一致。v2 改了三处:

  1. 大块 + 尾块:偏移都是 512 对齐,于是 bs=512 搬大块 → bs=4 搬次块 → 最后 1~3 字节 bs=1 收尾, 每条最多 3 个 dd(117 条从约 1600 万次单字节读写降到 351 次 dd 调用)
  2. 单条重试 3 次,写完先 sync 再读回比对
  3. 版本校验改为「记账但仍写」:设备上既不是原厂也不是已改(例如上次只写了一半)时不再跳过 —— 跳过去反而修不好那种半截状态;现在照样写,只在结论里记一笔

改动只在 Lua(负载字节没变),所以旧盘写到一半的状态,用新盘重跑一遍即可对齐。

v2 的 bug 与 v3 修复

v2 在真机上直接报错:

main.lua:58: attempt to call a nil value (global 'sh')
  in upvalue 'ddWrite' <- upvalue 'writeOne' <- upvalue 'tick'

根因是 Lua 的词法作用域:v2 把 segs/ddWrite/ddRead 插在了 ddBlk 后面(文件顶部), 而 sh / slurp 是后面才 local 声明的 —— 于是这两个函数里引用的 sh/slurp 是全局名字(nil), 一调用就炸。(v2 因此一个字节都没写进设备:报错发生在第一条记录的 dd 之前。)

v3 把整块挪到 slurp / sh / readChunk 之后、writeOne 之前,并加了一个「先调用后声明」静态检查, 以后这类错能在本机被抓到。

顺带把写入改成每段一个临时文件 + 只用 seek,不再依赖同一条 dd 里同时用输入 skip 和输出 seek。

盘 v3 体积 / sha256 校验
dist/S4PAnim.face 16,670,972 B / 4a10d1f83a0fd5a2… 11/11 通过
dist/S4PAnimRestore.face 16,685,460 B / 6318bc22698ea734… 11/11 通过

生成出来的 Lua 里关键函数顺序(已按依赖排好): slurp 528 → sh 536 → logOut 617 → readChunk 629 → segs 641 → ddWrite 656 → ddRead 670 → writeOne 685。

v4 —— 真机「黑屏」的根因(解码长度少了 1 字节)

真机现象:开始测量后动画完全不显示(黑),只偶尔闪一下原版前两帧。

根因在 header 的 usize 字段:

值
槽位 header 里的 usize(119 个槽位全是这个) 216,321 = 1024 + 464×464 + 1
原厂 stock 流解码出来的字节数 216,321(正好等于 usize)
我们生成的流解码出来的字节数 216,320(少 1 字节)✗

设备端的 RLE 解码器要求解码结果正好是 usize 字节,少一字节就判定失败 → 那一帧直接不画(黑屏)。 这解释了全部现象:

  • 帧 1~3 是「保留原厂」的三帧(stock 流,解码正常)→ 偶尔闪一下原版 ✅
  • 帧 4~56 是 v1 那轮写进去的坏流(解码失败)→ 黑 ✅
  • 帧 57~119 还没写(仍是原版)—— 但脉冲前段的画面本来就接近全黑,所以看不出 ✅

修复:在编码结果后面补一个「重复 1 次」的 token(0x01 0x00),它产出 1 个字节、落在像素区之后(不影响画面), 把解码长度凑到 usize;之后照旧用空指令补到 csize。

校验升级:tools/verify_anim.py 现在对交付的每一条记录都检查「解码长度 == usize」,不只检查能否解出图。

盘 v4 体积 / sha256 校验
dist/S4PAnim.face 16,670,844 B / 620cbdff87c1292c… 11/11 通过(含 usize 检查)
dist/S4PAnimRestore.face 16,685,328 B / 4fad8c5b0f5f48c4… 11/11 通过

v5 —— 诊断版(为了抓「总是失败 1」)

本机把 117 条记录都翻过一遍:全部 512 对齐、没有两条落在同一个 512 块、最大结束偏移离分区末端还有 132 MB、 97 条带 1~3 字节尾巴 —— 结构上没有哪一条特殊,所以得让设备自己说是哪一条。

v5 在写入失败时会打出一行:

> 失败 #N off=0xXXXXXXXX len=NNNNN 首差@i 得AA期BB

(也可能是「读回 x 字节」或「读回 nil」)。把这一行发我,就能定位是哪个文件、差在哪一个字节、是没写进去还是写错了。

v6 —— 「色彩失真」的根因与修法

真机反馈:动画看起来色彩失真。根因不是调色板通道顺序(那批 I8 控制中心图标已在真机验证过颜色正确), 而是颜色数被槽位预算逼到了 2~24 色 —— 平滑的蓝绿渐变被压成色阶断层(posterization):

v5 的颜色档 帧数
256 色 91 帧
32~128 色 9 帧
24 / 16 / 12 / 8 / 6 / 4 / 3 / 2 色 16 帧 ← 这些就是看起来失真的
保留原厂 3 帧

这 16 帧正好是脉冲的两端(Measuring4~11、Measuring111~119)—— 它们的槽位预算只有 5~46 KB, 而 v5 的策略是「宁可满尺寸 2 色也不缩小」,于是画面变成一整块平色 / 硬边横条。

v6 改了策略:

  1. 先在同一尺寸下降颜色,降到 32 色这个底线
  2. 还不够就缩小球体(1.0 → 0.9 → … → 0.42),而不是继续丢颜色
  3. 连「32 色 + 0.42 倍」都放不下的槽位(6 个最末尾的小槽位)保留原厂 —— 那几帧原厂本来就近乎全黑, 读起来正好是淡入/淡出

结果:113 帧替换,颜色全部 ≥ 32 色(其中 91 帧仍是 256 色),224 色的失真帧一个不剩; 代价是紧预算的帧球体略小(0.60.9 倍),观感上像呼吸的缩放。 想反过来(宁可平色也别缩小)只要把 tools/make_anim_payload.py 里的 ALLOW_BELOW_MIN = True 打开。

对照图:docs/anim_color_v5_vs_v6.png(上排 v5 失真版 / 下排 v6);预览视频 docs/anim_new.mp4。

盘 v6 体积 / sha256 校验
dist/S4PAnim.face 16,650,888 B / 09c042a332f84b33… 11/11 通过(含 usize 断言 + v5 的诊断日志)
dist/S4PAnimRestore.face 16,685,988 B / fc2afd109e5d24f9… 11/11 通过

v7 —— 不保留任何原机帧 + 两处写入加固

① 用户要求「不要保留原机动画」 —— v6 之前对预算太小的槽位会保留原厂字节,真机上那几个槽位会闪出原机的小球。 v7 改成一律替换:阶梯一路下探到「2 色 × 0.04 倍」,再不行就写一帧全透明占位,绝不 keep 原厂。 结果:119/119 全部替换(不再有 keep)。

帧 16(最紧的槽位,58 KB)实际编码
帧1 = 2 色 ×0.04(一个点)、帧2 = 2 色 ×0.09、帧3 = 3 色 ×0.22、帧4 = 2 色 ×0.22、帧5 = 16 色 ×0.22、帧6 = 32 色 ×0.28

为什么这么小:464×464 的 I8 图光是全黑背景就要 3.7 KB(RLE 单条游程上限 127,一行至少 4 个 token), 再加 1 KB 调色板 —— 这两个槽位的总预算只有 4.44.5 KB,物理上只能装一个小点。

② 「总是失败 1」的两处加固(v7):

  1. 去掉 canary 记录:它往偏移 0 写 32 字节(内容与原厂相同,纯属多余),而偏移 0 的写/回读恰恰最容易出岔子, 而且它永远是第一条——这与「每次都恰好失败 1」的现象高度吻合
  2. 加「计划 B」:常规「分段写」3 次不过时,改成整条一次 dd(长度能被 4 整除用 bs=4,否则 bs=1)再校验一次; 有些驱动对「同一区域被拆成多次小写」比「一次大写」更敏感
  3. 失败时依旧打印 > 失败 #N off=0x… len=… 首差@i 得AA期BB(诊断信息保留)

顺手修的:预览视频 docs/anim_new.mp4 之前是按偏移排序生成的,播出来顺序是乱的;v7 起按帧号生成。

盘 v7 体积 / sha256 校验
dist/S4PAnim.face 16,686,552 B / 5b1814e6177791ee… 11/11 通过(119 条 pay,无 canary)
dist/S4PAnimRestore.face 16,686,552 B / d5d92b6a0cd1cfc0… 11/11 通过

v8 —— 「忽大忽小」的根因与修法

真机反馈:结尾逐渐缩小的动画忽大忽小。根因是球体比例逐帧从离散档位里挑,而档位跟着槽位预算走 (预算继承自原厂脉冲、本身有小抖动)—— 相邻两帧可能落到差很远的档位,看起来就一跳一跳。

v8 改成两遍法:

  1. 量上限:对每一帧二分求「还能保住 ≥32 色的最大比例」cap(8 次二分,比离散档细得多)
  2. 平滑:从峰值向两侧取运行最小,得到单峰、单调、不含抖动的曲线
  3. 编码:严格按曲线的比例来,预算不够时只降颜色、不改尺寸;只有连 2 色都放不下时才允许再缩 10%
阶段 帧 比例
涨起来 1~12 0.02 → 0.04 → 0.09 → 0.18 → 0.31 → 0.45 → 0.62 → 0.70 → 0.81 → 0.99 → 1.00
保持 12~110 1.00(其中 91 帧 256 色)
落下去 111~119 1.00 → 0.93 → 0.90×4 → 0.81 → 0.77 → 0.72 → 0.66

曲线图 docs/anim_scale_profile.png;预览视频(按帧号)docs/anim_new.mp4。 全 119 帧里只有最前两帧(比例 0.02,约 9 像素的点)颜色低于 32。

盘 v8 体积 / sha256 校验
dist/S4PAnim.face 16,686,680 B / ce73d9ede1c9a574… 11/11 通过
dist/S4PAnimRestore.face 16,686,680 B / d16b5cfda35089b9… 11/11 通过