Files
o62lte-customising-plan/s5e-pressure-anim/README.md
T
2026-10-03 00:01:12 +08:00

21 KiB
Raw Blame History

S5ePAnim —— 把 S5 41mm(q63) 的压力检测动画搬到 S5 eSIM 46mm

把 Xiaomi Watch S5 41mm(q63, v4.101.020) 压力检测的 100 帧 480×480 动画, 逐帧等长覆盖写进 Xiaomi Watch S5 eSIM 46mm(p62lte, v3.112.035) /dev/health 里 pressure/measure/Measuring1..119.bin 的 119 个槽位(464×464 I8 + LVGL RLE)。

关系:s4e-pressure-anim 是「S5 41mm → S4 eSIM(466×466)」的母项目(只读参考)。 本项目沿用它的全部结构(两遍构建回填偏移、指纹表拆盘、逐字节等长覆盖、独立校验), 只把目标换成 S5 eSIM、界面重做成 480×480、写入节点换成 /dev/health。

交付物

文件 字节数 id / b5 sha256
dist/S5ePAnim.face 16,450,844 462150102 / b5=0 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c
dist/S5ePAnimRestore.face 16,450,844 462150102 / b5=0 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69

两份盘共用同一个表盘 id(462150102):装其中一份就会顶掉另一份,设备上始终只占一份空间 (合在一张盘要 ~31 MB,超体积 —— 这是当初拆盘的原因)。

  • S5ePAnim.face:pay = 替换后的 119 条(q63 的帧),rb = 原厂前 64 B 指纹(只给版本校验用)
  • S5ePAnimRestore.face:pay = 原厂 119 条(逐字节),rb = 替换后前 64 B 指纹

两张盘除了负载/指纹与 Lua 里的两张表以外没有任何差别(头 50 B、8 张界面素材、资源基址逐字节相同)。

装法与普通 Lua 表盘相同(把生成的 resource.bin 推到表盘目录,例如 /data/app/watchface/market/462150102/)。装好后在表盘上操作:

首页 →「快速帮助」→「环境预检查」          只读,一个字节都不写
首页 →「开始替换」→ 确认页 →「确认替换」     写入 119 条 → 「重启手表」
还原:装 dist/S5ePAnimRestore.face(同 id 顶掉替换盘)→「开始还原」→ 重启

源 / 目标

分区 槽位 尺寸 / 格式 单条大小 / 合计
目标 S5 eSIM 46mm(p62lte v3.112.035) inputs/target/vela_health.bin(/dev/health,卷名 health) pressure/measure/Measuring1..119.bin 464×464 I8 RLE,usize 216,321 = 1024 + 464×464 + 1 4,485 ~ 196,538 B,共 15.49 MB
源 S5 41mm(q63 v4.101.020) inputs/source/vela_app.bin pressure/measure/Measuring1..100.bin 480×480 I8 RLE,usize 231,424 = 1024 + 480×480(没有尾巴字节) ~182 KB / 帧
  • 目标镜像 sha256 489752c8a3fb24fff3e167610964795f0faaf830f142223b63be6ac2f57e67aa (206,924,800 B,sb_size 206,924,736,superblock cksum b804503a)—— 偏移锁死这一版固件。
  • 改后镜像 .work/anim/vela_health_anim.bin sha256 b11b72fcb38ad75a643d93d7db4dd366477ac33bfed93ef91096e04a73d67479。

映射:第 k 个槽位(k=1..119) ← q63 第 round((k-1)/(119-1)*(100-1))+1 帧(1..100)。 119 与 100 不是整数倍关系,所以有 19 帧被连续两个槽位复用(119↔100 比例重采样,不是 1:1), 帧号顺序与速度与原动画一致。

目标固件的事实(从固件包里查出来的)

项 结论 证据
写入分区 vela_health.bin,卷名 health,设备节点 /dev/health ROMFS superblock 卷名 = health;206,924,800 B
压力动画槽位 pressure/measure/Measuring1.bin … Measuring119.bin,119 帧 逐条枚举 ROMFS;编号连续 1..119
槽位格式 464×464,cf=0x0a(I8),flags=0x08(RLE),method=1,stride=464 每条 12 B 图像头 + 12 B 压缩头;119 条的 usize 全是 216,321
槽位长度 4,485 ~ 196,538 B(差 40 倍),合计 15.49 MB 逐条统计;槽位长度 = 24 B 头 + csize
源素材 q63 的 vela_app.bin 里 pressure/measure/Measuring1..100.bin q63 的 vela_health.bin 里没有 pressure/measure/
源格式 480×480 I8 + RLE,usize 231,424,约 182 KB/帧 底噪是接近黑的 #1a1a1a 抖动
表盘宿主 Lua 5.4,480×480 圆屏;Compiler.exe 对 DeviceType 462 强制 326×326 预览 同 s5e-icons 的实测结论

怎么做到「等长覆盖」

ROMFS 里每个文件的数据长度是硬编码的(后面所有文件的偏移都是绝对值),所以槽位必须逐字节等长替换: 槽位长度 = 24 B 头(12 B 图像头 + 12 B 压缩头)+ csize 字节 RLE 流。而两边尺寸不同、还都是 RLE, 压缩后长度各不相同。流水线(tools/s5e_anim.py):

  1. 暗部归零(能不能塞进去的前提):q63 的"黑"其实是一条接近黑的抖动底噪,熵极高; 阈值(亮度 < 40,部分帧 < 56)以下的像素一律钉成纯黑之后,背景塌成整行单游程, RLE 体积掉到约 1/4。球体最暗的实体像素远高于阈值,所以只吃底噪、不动画面。
  2. 缩放 + 量化:480×480 → 464×464,在一条 34 档的阶梯上挑一档 (256 色@1.00 → … → 32 色@0.70 → … → 2 色@0.05)。
  3. 平滑:以各帧"还能保住 ≥32 色"的最大缩放为上限,从峰值向两侧取运行最小值, 得到单峰、单调的曲线,避免相邻帧忽大忽小(S4e 真机反馈过"忽大忽小")。
  4. 编码:LVGL RLE(tools/lvgl_bin.py: rle_compress)。
  5. 补齐:pad_rle_safe 用两类产出 0 像素的合法指令把流补到 csize (00 00 = 重复 0 次;80 = 原样 0 个,用来修奇偶)。
  6. usize 尾巴:解码长度必须恰好等于 header 里的 usize。 目标槽位的 usize = 216,321 = 1024 + 464×464 + **1** —— 像素区之后还有 1 个字节; 我们编码时也补出这 1 个字节(usize_tail),否则设备端 RLE 解码器认为流不完整, 整帧直接不画(S4e 真机上就是这个现象:黑屏、只偶尔闪一下原版帧)。 s5e_anim.py 里对这条有显式断言(源世代是 0 字节尾巴、目标世代是 1 字节,两者不同)。

编码期的三条就地自证(每条负载都跑)

  1. len(decode(stream)) == usize(少一字节设备端就不画)
  2. 解出来的像素 == 量化结果(逐点)
  3. 量化结果的形状(非黑像素数 + bbox)与源图一致

第 3 条是被真 bug 逼出来的:早期版本量化时按 472 宽取像素、却读成 464 宽,整幅图被剪成 斜条纹 —— RLE 流完全正确、解出来也和"量化结果"逐点一致,只有比形状才抓得住。

替换质量:119/119 全部替换,kept_stock = [](没有一帧保留原厂)。 颜色分布:256 色 91 帧、192 色 1、128 色 2、96 色 1、64 色 7、48 色 3、32 色 6、 24 色 1、16 色 2、12 色 1、4 色 4。

  • 开头 8 帧(Measuring1..8,424 色、缩放 0.160.65)是设计如此:动画从一个小点长起来, 平滑曲线本身的目标档就在低端,不是被迫降级。
  • 14 帧(Measuring106..119)是被迫退档:平滑目标档(256 色@1.00)塞不进那 14 个偏小的槽位 (槽位 19,02577,823 B),退到 192/128/64/48/32 色 + 0.700.90 缩放;逐帧的 (ci, target_ci, how) 都在 .work/anim/manifest.json 的 records / degraded 字段里。
  • 预算利用率约 49%(编码 7.88 MB / 槽位 16.24 MB):源帧本身是抖动过的 256 色图, 去掉底噪后在 464×464 下的熵上限就是约 80 KB,而尾部槽位有 150~196 KB。 想把预算填满只能把源图的抖动颗粒一起搬过去(背景会泛灰,也不是有效细节), 权衡后选择保持画面干净 —— 这是有意的取舍,不是做不到。

真机怎么做

  1. 装 dist/S5ePAnim.face
  2. 表盘「快速帮助 → 环境预检查」(只读):确认 /dev/health 可读、卷名是 health、 119 条负载不越界(用 superblock 的大端 size 判),并对首/中/尾三条做抽样版本校验 (原厂✓ / 已替换✓)
  3. 首页「开始替换」→ 确认页 → 确认替换 → 等 成功 119 失败 0(15.49 MB)
  4. 「重启手表」→ 打开压力 App 开始检测,看整段动画
  5. 还原:装 dist/S5ePAnimRestore.face →「开始还原」→ 重启

「正在替换」页每 10 条打一行 > 写入 n/119 成功 x 失败 y,结束打 > 共 119 条,成功 x 失败 y。 判断成功的唯一标准是 失败 0;若出现 > 版本不符跳过 n 条,说明设备上的 /dev/health 不是算偏移时用的那一版固件 —— 不要重启,装对应版本的盘或先还原。

写入前每条都做版本校验:读设备上该偏移的前 64 B,必须等于「本盘要写的字节」或「另一侧的指纹」, 两边都不是就判版本不符,并跳过 /dev/health 剩余记录(绝不把字节写进别人的文件里)。 写入后用内容比对判成败(os.execute 的返回值在这台机器上不可靠),失败自动重试 3 次, 仍不过就退化成「整条一次 dd」。

为什么写入要分段(S4e 真机的教训)

119 个槽位全部 512 对齐,但 97/119 条的长度不是 4 的倍数(RLE 流长度随机)。 若按长度取块大小就只能退化成 bs=1 —— 一条 19.6 万字节的记录要 40 万次单字节读写, 真机上慢到偶发失败。本项目按「bs=512 大块 + bs=4 次块 + 最后 13 字节 bs=1」切成 ≤3 段, 每条最多 3 个 dd 调用。

界面(480×480)

设计稿只有一处:tools/s5e_pages.py(main.lua 用的就是同一套坐标), 用真 MiSans 渲染到 docs/ui_*.png,并用真实渲染像素判圆屏切边(tools/s5e_check_clip.py)。

圆屏的硬约束:在高度 y 处,屏幕的弦半宽 = √(240² − (y−240)²),越靠下越窄。所以:

  • 列表底板 radius=40 不是审美:y=88 那行弦半宽只有 185.8,圆角不够时四个角会越过圆边
  • 型号那一行拆成两行(Xiaomi Watch S5 / eSIM 46mm(p62lte)):整串 25px 真 MiSans 量得 367px,而 y=243 处弦半宽只有 ~190px,放一行必被圆边切掉
  • 说明文字每行都控制在 348px 以内(25px 下约 13 个中文字),否则折行后第 2 行会被圆边切
  • 多色文本不能用 LVGL recolor(会被原样显示成 #ffffff…),一律拆成多个标签 + 手工算 x

实测:7 个页面越界像素 0(最远点 238.4 / 半径 240),标签溢框 0。

重建

$env:PYTHONIOENCODING="utf-8"                # Windows 上必须,否则中文输出会炸
# 需要 Python 3 + Pillow (+ numpy)
python tools\s5e_anim.py all                 # 100 帧 -> 119 个等长槽位 + 改后整分区镜像(几分钟,几百 MB)
python tools\s5e_lua_static.py               # main.lua 静态体检(块配平 / 先用后声明 / 模板约定)

python tools\s5e_payload.py   replace        # 负载 + 指纹 -> payload/ + blob_replace/
python tools\s5e_overlay.py   replace        # 480x480 overlay(首页真渲染 -> 326x326 缩略图)
python tools\s5e_build.py     replace        # 两遍构建 -> dist\S5ePAnim.face(0 漂移校验)
python tools\s5e_verify.py    replace        # 逐盘校验(38 项)

python tools\s5e_payload.py   restore
python tools\s5e_overlay.py   restore
python tools\s5e_build.py     restore        # -> dist\S5ePAnimRestore.face
python tools\s5e_verify.py    restore

python tools\s5e_verify_indep.py             # 独立总校验(70 项,不读构建中间产物)

界面相关(改了 tools/s5e_pages.py / main.lua 之后):

python tools\s5e_ui_images.py                # assets/ -> ui_images/*.bin(8 张 LVGL v9 素材)
python tools\s5e_render_ui.py                # 480x480 预览 -> docs\ui_*.png + docs\ui_all.png(会报溢框)
python tools\s5e_check_clip.py               # 真实渲染像素判圆屏切边(0 越界才算过)

需要自己准备的输入(放进 inputs/,不进仓库)

路径 是什么 大小
inputs/target/vela_health.bin 目标固件(p62lte)health 分区 206,924,800
inputs/target/vela_app.bin 目标固件 app 分区(可选) 103,043,072
inputs/target/vela_font.bin 目标固件 font 分区(可选:渲染预览时从里面取 MiSans) 43,379,712
inputs/source/vela_app.bin 素材来源(S5 41mm q63)app 分区 206,000,128
inputs/fonts/MiSans-*.ttf 可选;不提供就自动从 inputs/target/vela_font.bin 里取 —
vendor/LuaDevTemplate-main/ 官方模板(里面有 Compiler.exe) —

固件分区镜像就是官方 OTA 包(ZIP)里的 vela_*.bin 条目,解压出来即可。 不要设 ICON_EDIT_VENDOR —— 每个项目自带 vendor/LuaDevTemplate-main/。

自证输出(全部真实执行过,原始输出在 docs/selfcheck_run.txt / docs/selfcheck_report.txt)

① 119 条负载长度逐条 == 原槽位长度

OK   每条负载长度 == 原槽位长度(119 条)                        <- s5e_verify.py(两盘各一次)
OK   第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符)  <- s5e_verify_indep.py:名字也对得上
OK   119 个目标偏移全部 512 对齐
     (97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1)

② 每条负载解出来都是 464×464,且 RLE 恰好 usize

OK   每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464
OK   每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符)
OK   每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符)

③ 改后镜像的 inode 表 / superblock 逐位不变、槽位外 0 字节变化

OK   inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变
OK   superblock 与卷名不变(size 206924736 / cksum b804503a / 'health')
OK   分区长度不变(206924800 B)
OK   除 119 个槽位外,其余 5275 个文件的字节**逐字节**不变(0 处不符)
OK   变化区间 0x2c18618..0x3b9d384 完全落在槽位区间 0x2c18600..0x3b9d385 内
OK   119 个槽位全部被改写(未变的:无)

④ .face 里 Lua 表反解出的偏移与镜像一致(0 漂移)+ 两张盘互证

OK   两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续)
OK   Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移      <- s5e_verify.py
偏移校验:全部吻合,0 漂移                                      <- s5e_build.py(两遍构建)
OK   replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == replace 的指纹
OK   119/119 条负载在两张盘里都不一样

总账

s5e_lua_static.py      : 全部通过(块配平 202/202;无先用后声明;4 个占位符各 1 次)
s5e_verify.py replace  : 38 项通过,0 项失败
s5e_verify.py restore  : 38 项通过,0 项失败
s5e_verify_indep.py    : 70 项通过,0 项失败
s5e_check_clip.py      : 7 个页面,越界 0(最远 238.4 / 半径 240)

构建期的两个坑(都在 tools/s5e_build.py 里修掉了)

① 交付物的 b5 曾经是 10(必须和固件自带表盘一样是 0) 两遍构建走的是 vendor 的 scripts/build_face.ps1,它编完之后会调 scripts/internal/set_face_id.ps1,里面有一句 $bytes[5] = [byte]$faceIdSize(= 10)—— 把 id 字符串长度写进了头里第 5 字节。现在最终产物取自裸 Compiler.exe -b 的输出 (b5 由编译器给出,实测 0),只覆盖 40..49 = id;并断言「裸产物与 toolchain 产物只差 b5 + id 这 8 个字节」,否则拒绝交付。

② 第二遍曾经编的还是占位符版 Lua main.lua 是带 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 的模板:第一遍编模板、量出资源偏移; 第二遍必须把回填后的 Lua 写回编译工作区再编。之前只把回填结果写到了 .work/overlay/...(给人看的副本),编译工作区里还是模板 → 交付物里的 Lua 带着占位符, 表盘在设备上会直接"负载表为空"。现在第二遍前会 write_lua(resolved)。

已知边界 / 没做的部分

  1. 真机未验证 S5 eSIM 的 /dev/health 可写:同源平台的 S4 eSIM 上 dd 读写流程实测通过, S5 eSIM 上只能先靠只读的「环境预检查」确认可读 + 偏移对版。整条 119 条的写入路径在本机 无法端到端验证(没有设备),本机只验证了字节、长度、偏移、头、镜像完整性。
  2. 偏移锁固件版本:换固件必须重跑 s5e_anim.py all → 4 步流水线。用错版本时表盘会判 「版本不符」并跳过 /dev/health 剩余记录(job.skipRest),不会把字节写进别人的文件里。
  3. 开头 8 帧与结尾 14 帧的画质:开头 8 帧只有 424 色(它们是"一个小点",槽位预算 4.517.5 KB);结尾 14 帧被预算逼到 32192 色 + 0.700.90 缩放。这是等长覆盖的硬约束 (464×464 的 I8 图光全黑背景就要 ~3.7 KB),不是编码 bug;要更大就得上「非等长写」, 那会动 inode 表和后面所有文件的偏移(本项目刻意不做)。
  4. Compiler.exe 的设备表里没有 480 档:圆表且预览 326×326 的只有 362 / 462, 所以用 DeviceType=462 + 326×326 预览图。预览只是表盘列表里的缩略图, 实际画面尺寸由 main.lua 与 .fprj 的 480 决定。
  5. 负载文件必须原样连续放在 resource.bin 里,偏移才等于 文件基址 + 魔数长度 + 相对偏移; s5e_build.py 会逐条用生成时的 sha256 校验,并做两遍 0 漂移检查。魔数长度是 15 B (S5P-PAYv1),曾经写成 16 导致全部偏移差 1。
  6. 分区键名 ≠ 节点路径:Lua 表里存 'health',运行期由 DEVS['health'] 取到 /dev/health。 把 /dev/health 直接存进表里,拼出来就是 if=nil。s5e_verify.py / s5e_lua_static.py 都专门检查这一条。
  7. 没动的:inode 表、目录项、superblock、任何非 pressure/measure/Measuring*.bin 的文件; 仓库里其它项目(s4e-*、s5e-icons、s5e-charge)与 git(未 commit、未改 .gitignore)。

第三方与风险

  • 本仓库不含固件、OTA 包、字体;.face 由第三方 Compiler.exe 编译
  • 往分区节点写数据是危险操作:写错偏移可能损坏资源分区。表盘自带版本校验与还原盘, 但请自行确认设备所有权并承担后果
  • 官方 OTA 升级会整分区覆盖,动画会回到原厂(等于自动还原)

目录

main.lua                 表盘源码(含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符,构建时回填)
tools/                   全部脚本(见下表)
ui_images/               界面素材(LVGL v9 .bin,8 张,480 档尺寸)
assets/                  设计素材 PNG(Mdi 图标、返回箭头、QQ、封面)
dist/                    交付的 .face
docs/                    界面预览 + selfcheck_run.txt / selfcheck_report.txt
inputs/                  固件分区镜像 + 字体(不进仓库,见上)
vendor/                  LuaDevTemplate-main(含 Compiler.exe,不进仓库)
.work/                   构建工作区(anim/ 改后镜像、overlay/、build/;可删)
payload/ payload_restore/     逐条目负载 + payload.lua(人肉 dd 用)
blob_replace/ blob_restore/   魔数 + 顺序拼接的 payload.bin / fp.bin / offsets.json
脚本 作用
tools/paths.py 所有路径的唯一入口(本项目事实:id / 480 / 464 / 119 都在这)
tools/romfs.py ROMFS 分区解析(只读)
tools/lvgl_bin.py LVGL v9 RLE 编解码 + pad_rle_safe
tools/iconpack.py ARGB8888 / I8 图片编码(界面素材用)
tools/lua_balance.py Lua 块配平(被 s5e_lua_static.py 调用)
tools/s5e_anim.py 核心:100 帧 → 119 个等长槽位 + 改后整分区镜像 + manifest.json(含三条就地自证)
tools/s5e_payload.py 按角色切负载 + 指纹表 + blob_*/(含逐条 sha256)
tools/s5e_overlay.py 生成可编译的 overlay(config / fprj / 预览图 / 资源)
tools/s5e_build.py 两遍构建 + 裸编忠实产物(b5=0)+ 0 漂移校验 → dist/*.face
tools/s5e_verify.py 逐盘校验(38 项):头 / Lua / 偏移 / 逐条负载 / 指纹 / 镜像 / UI / 体积
tools/s5e_verify_indep.py 独立总校验(70 项):不读构建中间产物的那一路 + 两张盘互证
tools/s5e_lua_static.py main.lua 静态体检(含 S4e 踩过的「先调用后声明」坑)
tools/s5e_pages.py 480×480 页面规格(唯一的设计稿来源)
tools/s5e_render_ui.py 用真 MiSans 渲染 7 页预览(会报标签溢框)
tools/s5e_check_clip.py 用真实渲染像素判圆屏是否切边
tools/s5e_ui_images.py 设计素材 PNG → ui_images/*.bin + 圆形 logo