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

320 lines
21 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.
# 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`,4~24 色、缩放 0.16~0.65)是**设计如此**:动画从一个小点长起来,
平滑曲线本身的目标档就在低端,不是被迫降级。
* **14 帧**(`Measuring106..119`)是**被迫退档**:平滑目标档(256 色@1.00)塞不进那 14 个偏小的槽位
(槽位 19,025~77,823 B),退到 192/128/64/48/32 色 + 0.70~0.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` 次块 + 最后 1~3 字节 `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。
## 重建
```powershell
$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` 之后):
```powershell
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 帧只有 4~24 色(它们是"一个小点",槽位预算
4.5~17.5 KB);结尾 14 帧被预算逼到 32~192 色 + 0.70~0.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 |