增添 S5 支持
@@ -0,0 +1,10 @@
|
||||
# 大文件 / 中间产物:不进仓库
|
||||
.work/
|
||||
inputs/
|
||||
vendor/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
payload/
|
||||
payload_restore/
|
||||
blob_replace/
|
||||
blob_restore/
|
||||
@@ -0,0 +1,319 @@
|
||||
# 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 |
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 470 B |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,298 @@
|
||||
# S5ePAnim 交付前自证(全部只读;UTF-8)
|
||||
# 生成时间 2026-10-02 22:34:54
|
||||
# python C:\Users\hi\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe
|
||||
|
||||
==============================================================================
|
||||
$ python tools/s5e_lua_static.py
|
||||
==============================================================================
|
||||
|
||||
ALL OK
|
||||
=== tools/s5e_lua_static.py D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\main.lua (31141 B) ===
|
||||
|
||||
[1] 块配平
|
||||
OK openers=202 end=202(function=90 if=99 for=11 while=1 do=1)repeat=0 until=0
|
||||
|
||||
[2] 先用后声明(真机上会变成 "call a nil value")
|
||||
OK 没有"先调用后 local":
|
||||
OK 没有未声明的被调用名:
|
||||
|
||||
[3] 模板约定(s5e_verify.py 靠这些从 .face 里反解 Lua)
|
||||
OK 占位符 __UIIMG__ 出现 1 次(要 1)
|
||||
OK 占位符 __PAY__ 出现 1 次(要 1)
|
||||
OK 占位符 __RB__ 出现 1 次(要 1)
|
||||
OK 占位符 __ROLE__ 出现 1 次(要 1)
|
||||
OK 文件以 --[[-- 开头
|
||||
OK --[[ 只出现 1 次(要 1,否则 verify 会从中间截 Lua)
|
||||
OK 以 return ui\n 结尾
|
||||
OK return ui\n 只出现 1 次(要 1)
|
||||
OK 有 local UIIMG = {
|
||||
|
||||
[4] 与 s5e_verify.py 对齐的字面量
|
||||
OK local ROLE = '__ROLE__'
|
||||
OK local W, H = 480, 480
|
||||
OK DEVS 里有 health -> /dev/health:health = "/dev/health"
|
||||
OK DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设)
|
||||
OK PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名)
|
||||
OK 没有残留的 466(旧 S4e 尺寸)
|
||||
|
||||
=== 全部通过 ===
|
||||
EXIT=0
|
||||
|
||||
==============================================================================
|
||||
$ python tools/s5e_verify.py replace
|
||||
==============================================================================
|
||||
==============================================================================
|
||||
S5ePAnim(role=replace) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnim.face
|
||||
==============================================================================
|
||||
|
||||
[1] .face 头
|
||||
OK magic 5a a5 34 12
|
||||
OK b4 = 0
|
||||
OK b5 = 0(固件自带表盘 b5 也是 0)
|
||||
OK id = b'462150102\x00'(期望 '462150102')
|
||||
OK preview 326x326(Compiler.exe 对 462 设备强制 326x326)
|
||||
OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c)
|
||||
文件 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c
|
||||
|
||||
[2] 从 .face 里反解加载的 Lua
|
||||
OK 找到 Lua 结尾 "return ui\n"
|
||||
OK Lua 提取成功(37900 B)
|
||||
OK Lua 原样出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'replace'
|
||||
OK 占位符已全部回填
|
||||
OK 界面尺寸 W,H = 480,480(480x480 圆屏)
|
||||
OK DEVS 里有 health -> /dev/health:health = "/dev/health"
|
||||
|
||||
[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)
|
||||
OK payload 魔数(15 B)在 .face 里唯一
|
||||
OK fp 魔数(14 B)在 .face 里唯一
|
||||
OK payload.bin 原样连续存放在 0xb16a
|
||||
OK fp.bin 原样连续存放在 0x205
|
||||
OK 负载记录 119 条(期望 119)
|
||||
OK payload.bin 16238747 B 在 .face 里逐字节连续
|
||||
OK fp.bin 7630 B 在 .face 里逐字节连续
|
||||
OK Lua 表 pay 119 条 / rb 119 条
|
||||
OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移
|
||||
OK 原厂 pressure/measure/ 下 119 个槽位
|
||||
OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321
|
||||
|
||||
[4] 逐条负载
|
||||
OK 每条负载长度 == 原槽位长度(119 条)
|
||||
OK 每条负载字节 == 改后镜像对应位置
|
||||
OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464
|
||||
|
||||
[5] 版本校验指纹
|
||||
OK 119 条指纹都是 64 B 且 == 原厂镜像前 64 B
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)
|
||||
|
||||
[6] 改后镜像的完整性
|
||||
OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变
|
||||
OK superblock 一致(size 206924736 / cksum b804503a)
|
||||
OK 卷名 health 不变
|
||||
OK 分区长度不变(206924800 B)
|
||||
OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385)
|
||||
OK 119 个槽位全部都真的写入了新字节(未变的槽位:无)
|
||||
|
||||
[7] UI 图片
|
||||
OK UIIMG 条目 8 条(期望 8)
|
||||
OK 8 张界面素材都与 ui_images/*.bin 逐字节一致
|
||||
|
||||
[8] 体积
|
||||
OK 交付物 15.69 MB < 20 MB
|
||||
两张盘同 id:装一份就顶掉另一份,设备上只占一份空间
|
||||
|
||||
==============================================================================
|
||||
S5ePAnim:38 项通过,0 项失败
|
||||
全部通过 —— S5ePAnim.face(16450844 B,sha256 d0bb012a364fd46e)
|
||||
EXIT=0
|
||||
|
||||
==============================================================================
|
||||
$ python tools/s5e_verify.py restore
|
||||
==============================================================================
|
||||
==============================================================================
|
||||
S5ePAnimRestore(role=restore) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnimRestore.face
|
||||
==============================================================================
|
||||
|
||||
[1] .face 头
|
||||
OK magic 5a a5 34 12
|
||||
OK b4 = 0
|
||||
OK b5 = 0(固件自带表盘 b5 也是 0)
|
||||
OK id = b'462150102\x00'(期望 '462150102')
|
||||
OK preview 326x326(Compiler.exe 对 462 设备强制 326x326)
|
||||
OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c)
|
||||
文件 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69
|
||||
|
||||
[2] 从 .face 里反解加载的 Lua
|
||||
OK 找到 Lua 结尾 "return ui\n"
|
||||
OK Lua 提取成功(37900 B)
|
||||
OK Lua 原样出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'restore'
|
||||
OK 占位符已全部回填
|
||||
OK 界面尺寸 W,H = 480,480(480x480 圆屏)
|
||||
OK DEVS 里有 health -> /dev/health:health = "/dev/health"
|
||||
|
||||
[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)
|
||||
OK payload 魔数(15 B)在 .face 里唯一
|
||||
OK fp 魔数(14 B)在 .face 里唯一
|
||||
OK payload.bin 原样连续存放在 0xb16a
|
||||
OK fp.bin 原样连续存放在 0x205
|
||||
OK 负载记录 119 条(期望 119)
|
||||
OK payload.bin 16238747 B 在 .face 里逐字节连续
|
||||
OK fp.bin 7630 B 在 .face 里逐字节连续
|
||||
OK Lua 表 pay 119 条 / rb 119 条
|
||||
OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移
|
||||
OK 原厂 pressure/measure/ 下 119 个槽位
|
||||
OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321
|
||||
|
||||
[4] 逐条负载
|
||||
OK 每条负载长度 == 原槽位长度(119 条)
|
||||
OK 每条负载字节 == 原厂镜像对应位置
|
||||
OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464
|
||||
|
||||
[5] 版本校验指纹
|
||||
OK 119 条指纹都是 64 B 且 == 改后镜像前 64 B
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)
|
||||
|
||||
[6] 改后镜像的完整性
|
||||
OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变
|
||||
OK superblock 一致(size 206924736 / cksum b804503a)
|
||||
OK 卷名 health 不变
|
||||
OK 分区长度不变(206924800 B)
|
||||
OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385)
|
||||
OK 119 个槽位全部都真的写入了新字节(未变的槽位:无)
|
||||
|
||||
[7] UI 图片
|
||||
OK UIIMG 条目 8 条(期望 8)
|
||||
OK 8 张界面素材都与 ui_images/*.bin 逐字节一致
|
||||
|
||||
[8] 体积
|
||||
OK 交付物 15.69 MB < 20 MB
|
||||
两张盘同 id:装一份就顶掉另一份,设备上只占一份空间
|
||||
|
||||
==============================================================================
|
||||
S5ePAnimRestore:38 项通过,0 项失败
|
||||
全部通过 —— S5ePAnimRestore.face(16450844 B,sha256 13cafae4bf2b03db)
|
||||
EXIT=0
|
||||
|
||||
==============================================================================
|
||||
$ python tools/s5e_verify_indep.py
|
||||
==============================================================================
|
||||
==============================================================================
|
||||
独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build)
|
||||
交付物 : dist/S5ePAnim.face + dist/S5ePAnimRestore.face
|
||||
固件 : inputs/target/vela_health.bin 206924800 B(卷名 health)
|
||||
改后 : .work/anim/vela_health_anim.bin 206924800 B
|
||||
==============================================================================
|
||||
OK 固件里 pressure/measure/ 下 119 个槽位(期望 119)
|
||||
OK 槽位名字就是 Measuring1..119.bin
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
[replace] S5ePAnim.face 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c
|
||||
------------------------------------------------------------------------------
|
||||
OK magic
|
||||
OK b5 = 0(固件自带表盘是 0)
|
||||
OK b4 = 0
|
||||
OK id = b'462150102\x00'
|
||||
OK preview 326x326
|
||||
OK Lua 在文件里只出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'replace'
|
||||
OK 占位符 __UIIMG__ 已回填
|
||||
OK 占位符 __PAY__ 已回填
|
||||
OK 占位符 __RB__ 已回填
|
||||
OK 占位符 __ROLE__ 已回填
|
||||
OK 界面尺寸 W,H = 480,480
|
||||
OK DEVS 有 health -> /dev/health
|
||||
OK UIIMG 8 条(期望 8)
|
||||
OK pay 119 条 / rb 119 条(期望 119/119)
|
||||
OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205)
|
||||
OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续)
|
||||
OK rb 每条都是 64 B
|
||||
OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符)
|
||||
OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符)
|
||||
OK 119 个目标偏移全部 512 对齐
|
||||
(97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1)
|
||||
OK 每条负载字节 == 改后镜像对应位置(0 处不符)
|
||||
OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0)
|
||||
OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符)
|
||||
OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符)
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同
|
||||
OK rb 的 target 也指向对应槽位
|
||||
OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符)
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
[restore] S5ePAnimRestore.face 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69
|
||||
------------------------------------------------------------------------------
|
||||
OK magic
|
||||
OK b5 = 0(固件自带表盘是 0)
|
||||
OK b4 = 0
|
||||
OK id = b'462150102\x00'
|
||||
OK preview 326x326
|
||||
OK Lua 在文件里只出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'restore'
|
||||
OK 占位符 __UIIMG__ 已回填
|
||||
OK 占位符 __PAY__ 已回填
|
||||
OK 占位符 __RB__ 已回填
|
||||
OK 占位符 __ROLE__ 已回填
|
||||
OK 界面尺寸 W,H = 480,480
|
||||
OK DEVS 有 health -> /dev/health
|
||||
OK UIIMG 8 条(期望 8)
|
||||
OK pay 119 条 / rb 119 条(期望 119/119)
|
||||
OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205)
|
||||
OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续)
|
||||
OK rb 每条都是 64 B
|
||||
OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符)
|
||||
OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符)
|
||||
OK 119 个目标偏移全部 512 对齐
|
||||
(97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1)
|
||||
OK 每条负载字节 == 原厂镜像对应位置(0 处不符)
|
||||
OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0)
|
||||
OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符)
|
||||
OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符)
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同
|
||||
OK rb 的 target 也指向对应槽位
|
||||
OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符)
|
||||
|
||||
==============================================================================
|
||||
[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像)
|
||||
==============================================================================
|
||||
OK 两张盘字节数相同(16450844)
|
||||
OK 头 50 B(magic / b4 / b5 / id)逐字节相同
|
||||
OK replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == replace 的指纹(0 处不符)
|
||||
OK 两张盘的 8 张界面素材逐字节相同(同一套界面)
|
||||
OK 两张盘的资源基址相同(0xb16a / 0x205)
|
||||
OK 119/119 条负载在两张盘里都不一样(119)
|
||||
|
||||
==============================================================================
|
||||
[镜像] 改后镜像 vs 原厂镜像
|
||||
==============================================================================
|
||||
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 个槽位全部被改写(未变的:无)
|
||||
|
||||
==============================================================================
|
||||
独立总校验:70 项通过,0 项失败
|
||||
==============================================================================
|
||||
EXIT=0
|
||||
|
||||
==============================================================================
|
||||
$ python tools/s5e_check_clip.py
|
||||
==============================================================================
|
||||
page max r outside px worst point
|
||||
--------------------------------------------------------------
|
||||
home 230.7 0 (275, 12)
|
||||
help 230.7 0 (275, 12)
|
||||
help_ok 230.7 0 (275, 12)
|
||||
help_err 230.7 0 (275, 12)
|
||||
confirm 238.4 0 (133, 453)
|
||||
running 238.4 0 (133, 453)
|
||||
about 232.3 0 (61, 92)
|
||||
--------------------------------------------------------------
|
||||
圆心 (240,240) 半径 240 画布 480x480 越界页数:0
|
||||
EXIT=0
|
||||
|
||||
==============================================================================
|
||||
ALL_STEPS_OK=True(5 步,0 步失败)
|
||||
==============================================================================
|
||||
@@ -0,0 +1,347 @@
|
||||
# S5ePAnim 重建 + 自证 原始输出(2026-10-02 22:24:49)
|
||||
# python: C:\Users\hi\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe
|
||||
|
||||
=== python tools\s5e_lua_static.py ===
|
||||
|
||||
ALL OK
|
||||
=== tools/s5e_lua_static.py D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\main.lua (31141 B) ===
|
||||
|
||||
[1] 块配平
|
||||
OK openers=202 end=202(function=90 if=99 for=11 while=1 do=1)repeat=0 until=0
|
||||
|
||||
[2] 先用后声明(真机上会变成 "call a nil value")
|
||||
OK 没有"先调用后 local":
|
||||
OK 没有未声明的被调用名:
|
||||
|
||||
[3] 模板约定(s5e_verify.py 靠这些从 .face 里反解 Lua)
|
||||
OK 占位符 __UIIMG__ 出现 1 次(要 1)
|
||||
OK 占位符 __PAY__ 出现 1 次(要 1)
|
||||
OK 占位符 __RB__ 出现 1 次(要 1)
|
||||
OK 占位符 __ROLE__ 出现 1 次(要 1)
|
||||
OK 文件以 --[[-- 开头
|
||||
OK --[[ 只出现 1 次(要 1,否则 verify 会从中间截 Lua)
|
||||
OK 以 return ui\n 结尾
|
||||
OK return ui\n 只出现 1 次(要 1)
|
||||
OK 有 local UIIMG = {
|
||||
|
||||
[4] 与 s5e_verify.py 对齐的字面量
|
||||
OK local ROLE = '__ROLE__'
|
||||
OK local W, H = 480, 480
|
||||
OK DEVS 里有 health -> /dev/health:health = "/dev/health"
|
||||
OK DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设)
|
||||
OK PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名)
|
||||
OK 没有残留的 466(旧 S4e 尺寸)
|
||||
|
||||
=== 全部通过 ===
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_payload.py replace ===
|
||||
blob_replace/ 单条目: payload.bin 16238747 B + fp.bin 7630 B(119 条记录,跳过 0 条未变)
|
||||
role=replace: emitted 119 records, 15.49 MB; dropped 0
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_overlay.py replace ===
|
||||
overlay written to D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\overlay\S5ePAnim
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_build.py replace ===
|
||||
=== S5ePAnim(role=replace,id=462150102)===
|
||||
第 1 遍:用占位符编一次,量出资源在 .face 里的位置
|
||||
| -------------------------------------------------------------------------------
|
||||
| Patched face ID: 462150102
|
||||
| Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\bin\S5ePAnim.face
|
||||
| Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\watchface\data\resource.bin
|
||||
face 16444088 bytes
|
||||
back off=0xf87a2e len=2100
|
||||
ic_help off=0xf8a105 len=3856
|
||||
ic_replace off=0xf8bf7c len=3856
|
||||
ic_about off=0xf8828e len=3856
|
||||
ic_check off=0xf891ca len=3856
|
||||
ic_rollback off=0xf8ceb9 len=3856
|
||||
ic_qq off=0xf8b03f len=3856
|
||||
logo off=0xf8ddf2 len=25612
|
||||
payload base=0x00b16a(16238747 B) / fp base=0x000205(7630 B),共 119 条记录
|
||||
第 2 遍:回填后的 Lua(32814 bytes)重新编译
|
||||
| -------------------------------------------------------------------------------
|
||||
| Patched face ID: 462150102
|
||||
| Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\bin\S5ePAnim.face
|
||||
| Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\watchface\data\resource.bin
|
||||
偏移校验:全部吻合,0 漂移
|
||||
忠实产物 vs toolchain:8 字节差异(只允许 b5 + id 这 8 个位置:[5, 40, 42, 43, 44, 46, 47, 48])
|
||||
b4=0 b5=0 id=b'462150102\x00'
|
||||
preview @0xf9d62c: 326x326 palette=1024 data=77538
|
||||
交付物:D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnim.face 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_verify.py replace ===
|
||||
==============================================================================
|
||||
S5ePAnim(role=replace) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnim.face
|
||||
==============================================================================
|
||||
|
||||
[1] .face 头
|
||||
OK magic 5a a5 34 12
|
||||
OK b4 = 0
|
||||
OK b5 = 0(固件自带表盘 b5 也是 0)
|
||||
OK id = b'462150102\x00'(期望 '462150102')
|
||||
OK preview 326x326(Compiler.exe 对 462 设备强制 326x326)
|
||||
OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c)
|
||||
文件 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c
|
||||
|
||||
[2] 从 .face 里反解加载的 Lua
|
||||
OK 找到 Lua 结尾 "return ui\n"
|
||||
OK Lua 提取成功(37900 B)
|
||||
OK Lua 原样出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'replace'
|
||||
OK 占位符已全部回填
|
||||
OK 界面尺寸 W,H = 480,480(480x480 圆屏)
|
||||
OK DEVS 里有 health -> /dev/health:health = "/dev/health"
|
||||
|
||||
[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)
|
||||
OK payload 魔数(15 B)在 .face 里唯一
|
||||
OK fp 魔数(14 B)在 .face 里唯一
|
||||
OK payload.bin 原样连续存放在 0xb16a
|
||||
OK fp.bin 原样连续存放在 0x205
|
||||
OK 负载记录 119 条(期望 119)
|
||||
OK payload.bin 16238747 B 在 .face 里逐字节连续
|
||||
OK fp.bin 7630 B 在 .face 里逐字节连续
|
||||
OK Lua 表 pay 119 条 / rb 119 条
|
||||
OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移
|
||||
OK 原厂 pressure/measure/ 下 119 个槽位
|
||||
OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321
|
||||
|
||||
[4] 逐条负载
|
||||
OK 每条负载长度 == 原槽位长度(119 条)
|
||||
OK 每条负载字节 == 改后镜像对应位置
|
||||
OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464
|
||||
|
||||
[5] 版本校验指纹
|
||||
OK 119 条指纹都是 64 B 且 == 原厂镜像前 64 B
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)
|
||||
|
||||
[6] 改后镜像的完整性
|
||||
OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变
|
||||
OK superblock 一致(size 206924736 / cksum b804503a)
|
||||
OK 卷名 health 不变
|
||||
OK 分区长度不变(206924800 B)
|
||||
OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385)
|
||||
OK 119 个槽位全部都真的写入了新字节(未变的槽位:无)
|
||||
|
||||
[7] UI 图片
|
||||
OK UIIMG 条目 8 条(期望 8)
|
||||
OK 8 张界面素材都与 ui_images/*.bin 逐字节一致
|
||||
|
||||
[8] 体积
|
||||
OK 交付物 15.69 MB < 20 MB
|
||||
两张盘同 id:装一份就顶掉另一份,设备上只占一份空间
|
||||
|
||||
==============================================================================
|
||||
S5ePAnim:38 项通过,0 项失败
|
||||
全部通过 —— S5ePAnim.face(16450844 B,sha256 d0bb012a364fd46e)
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_payload.py restore ===
|
||||
blob_restore/ 单条目: payload.bin 16238747 B + fp.bin 7630 B(119 条记录,跳过 0 条未变)
|
||||
role=restore: emitted 119 records, 15.49 MB; dropped 0
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_overlay.py restore ===
|
||||
overlay written to D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\overlay\S5ePAnimRestore
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_build.py restore ===
|
||||
=== S5ePAnimRestore(role=restore,id=462150102)===
|
||||
第 1 遍:用占位符编一次,量出资源在 .face 里的位置
|
||||
| -------------------------------------------------------------------------------
|
||||
| Patched face ID: 462150102
|
||||
| Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\bin\S5ePAnimRestore.face
|
||||
| Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\watchface\data\resource.bin
|
||||
face 16444088 bytes
|
||||
back off=0xf87a2e len=2100
|
||||
ic_help off=0xf8a105 len=3856
|
||||
ic_replace off=0xf8bf7c len=3856
|
||||
ic_about off=0xf8828e len=3856
|
||||
ic_check off=0xf891ca len=3856
|
||||
ic_rollback off=0xf8ceb9 len=3856
|
||||
ic_qq off=0xf8b03f len=3856
|
||||
logo off=0xf8ddf2 len=25612
|
||||
payload base=0x00b16a(16238747 B) / fp base=0x000205(7630 B),共 119 条记录
|
||||
第 2 遍:回填后的 Lua(32814 bytes)重新编译
|
||||
| -------------------------------------------------------------------------------
|
||||
| Patched face ID: 462150102
|
||||
| Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\bin\S5ePAnimRestore.face
|
||||
| Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\watchface\data\resource.bin
|
||||
偏移校验:全部吻合,0 漂移
|
||||
忠实产物 vs toolchain:8 字节差异(只允许 b5 + id 这 8 个位置:[5, 40, 42, 43, 44, 46, 47, 48])
|
||||
b4=0 b5=0 id=b'462150102\x00'
|
||||
preview @0xf9d62c: 326x326 palette=1024 data=77538
|
||||
交付物:D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnimRestore.face 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_verify.py restore ===
|
||||
==============================================================================
|
||||
S5ePAnimRestore(role=restore) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnimRestore.face
|
||||
==============================================================================
|
||||
|
||||
[1] .face 头
|
||||
OK magic 5a a5 34 12
|
||||
OK b4 = 0
|
||||
OK b5 = 0(固件自带表盘 b5 也是 0)
|
||||
OK id = b'462150102\x00'(期望 '462150102')
|
||||
OK preview 326x326(Compiler.exe 对 462 设备强制 326x326)
|
||||
OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c)
|
||||
文件 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69
|
||||
|
||||
[2] 从 .face 里反解加载的 Lua
|
||||
OK 找到 Lua 结尾 "return ui\n"
|
||||
OK Lua 提取成功(37900 B)
|
||||
OK Lua 原样出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'restore'
|
||||
OK 占位符已全部回填
|
||||
OK 界面尺寸 W,H = 480,480(480x480 圆屏)
|
||||
OK DEVS 里有 health -> /dev/health:health = "/dev/health"
|
||||
|
||||
[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)
|
||||
OK payload 魔数(15 B)在 .face 里唯一
|
||||
OK fp 魔数(14 B)在 .face 里唯一
|
||||
OK payload.bin 原样连续存放在 0xb16a
|
||||
OK fp.bin 原样连续存放在 0x205
|
||||
OK 负载记录 119 条(期望 119)
|
||||
OK payload.bin 16238747 B 在 .face 里逐字节连续
|
||||
OK fp.bin 7630 B 在 .face 里逐字节连续
|
||||
OK Lua 表 pay 119 条 / rb 119 条
|
||||
OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移
|
||||
OK 原厂 pressure/measure/ 下 119 个槽位
|
||||
OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321
|
||||
|
||||
[4] 逐条负载
|
||||
OK 每条负载长度 == 原槽位长度(119 条)
|
||||
OK 每条负载字节 == 原厂镜像对应位置
|
||||
OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464
|
||||
|
||||
[5] 版本校验指纹
|
||||
OK 119 条指纹都是 64 B 且 == 改后镜像前 64 B
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)
|
||||
|
||||
[6] 改后镜像的完整性
|
||||
OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变
|
||||
OK superblock 一致(size 206924736 / cksum b804503a)
|
||||
OK 卷名 health 不变
|
||||
OK 分区长度不变(206924800 B)
|
||||
OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385)
|
||||
OK 119 个槽位全部都真的写入了新字节(未变的槽位:无)
|
||||
|
||||
[7] UI 图片
|
||||
OK UIIMG 条目 8 条(期望 8)
|
||||
OK 8 张界面素材都与 ui_images/*.bin 逐字节一致
|
||||
|
||||
[8] 体积
|
||||
OK 交付物 15.69 MB < 20 MB
|
||||
两张盘同 id:装一份就顶掉另一份,设备上只占一份空间
|
||||
|
||||
==============================================================================
|
||||
S5ePAnimRestore:38 项通过,0 项失败
|
||||
全部通过 —— S5ePAnimRestore.face(16450844 B,sha256 13cafae4bf2b03db)
|
||||
EXIT=0
|
||||
|
||||
=== python tools\s5e_verify_indep.py ===
|
||||
==============================================================================
|
||||
独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build)
|
||||
交付物 : dist/S5ePAnim.face + dist/S5ePAnimRestore.face
|
||||
固件 : inputs/target/vela_health.bin 206924800 B(卷名 health)
|
||||
改后 : .work/anim/vela_health_anim.bin 206924800 B
|
||||
==============================================================================
|
||||
OK 固件里 pressure/measure/ 下 119 个槽位(期望 119)
|
||||
OK 槽位名字就是 Measuring1..119.bin
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
[replace] S5ePAnim.face 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c
|
||||
------------------------------------------------------------------------------
|
||||
OK magic
|
||||
OK b5 = 0(固件自带表盘是 0)
|
||||
OK b4 = 0
|
||||
OK id = b'462150102\x00'
|
||||
OK preview 326x326
|
||||
OK Lua 在文件里只出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'replace'
|
||||
OK 占位符 __UIIMG__ 已回填
|
||||
OK 占位符 __PAY__ 已回填
|
||||
OK 占位符 __RB__ 已回填
|
||||
OK 占位符 __ROLE__ 已回填
|
||||
OK 界面尺寸 W,H = 480,480
|
||||
OK DEVS 有 health -> /dev/health
|
||||
OK UIIMG 8 条(期望 8)
|
||||
OK pay 119 条 / rb 119 条(期望 119/119)
|
||||
OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205)
|
||||
OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续)
|
||||
OK rb 每条都是 64 B
|
||||
OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符)
|
||||
OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符)
|
||||
OK 119 个目标偏移全部 512 对齐
|
||||
(97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1)
|
||||
OK 每条负载字节 == 改后镜像对应位置(0 处不符)
|
||||
OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0)
|
||||
OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符)
|
||||
OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符)
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同
|
||||
OK rb 的 target 也指向对应槽位
|
||||
OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符)
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
[restore] S5ePAnimRestore.face 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69
|
||||
------------------------------------------------------------------------------
|
||||
OK magic
|
||||
OK b5 = 0(固件自带表盘是 0)
|
||||
OK b4 = 0
|
||||
OK id = b'462150102\x00'
|
||||
OK preview 326x326
|
||||
OK Lua 在文件里只出现 1 次(偏移 0xf94220)
|
||||
OK Lua 里的 ROLE == 'restore'
|
||||
OK 占位符 __UIIMG__ 已回填
|
||||
OK 占位符 __PAY__ 已回填
|
||||
OK 占位符 __RB__ 已回填
|
||||
OK 占位符 __ROLE__ 已回填
|
||||
OK 界面尺寸 W,H = 480,480
|
||||
OK DEVS 有 health -> /dev/health
|
||||
OK UIIMG 8 条(期望 8)
|
||||
OK pay 119 条 / rb 119 条(期望 119/119)
|
||||
OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205)
|
||||
OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续)
|
||||
OK rb 每条都是 64 B
|
||||
OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符)
|
||||
OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符)
|
||||
OK 119 个目标偏移全部 512 对齐
|
||||
(97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1)
|
||||
OK 每条负载字节 == 原厂镜像对应位置(0 处不符)
|
||||
OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0)
|
||||
OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符)
|
||||
OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符)
|
||||
OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同
|
||||
OK rb 的 target 也指向对应槽位
|
||||
OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符)
|
||||
|
||||
==============================================================================
|
||||
[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像)
|
||||
==============================================================================
|
||||
OK 两张盘字节数相同(16450844)
|
||||
OK 头 50 B(magic / b4 / b5 / id)逐字节相同
|
||||
OK replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == replace 的指纹(0 处不符)
|
||||
OK 两张盘的 8 张界面素材逐字节相同(同一套界面)
|
||||
OK 两张盘的资源基址相同(0xb16a / 0x205)
|
||||
OK 119/119 条负载在两张盘里都不一样(119)
|
||||
|
||||
==============================================================================
|
||||
[镜像] 改后镜像 vs 原厂镜像
|
||||
==============================================================================
|
||||
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 个槽位全部被改写(未变的:无)
|
||||
|
||||
==============================================================================
|
||||
独立总校验:70 项通过,0 项失败
|
||||
==============================================================================
|
||||
EXIT=0
|
||||
|
||||
ALL_STEPS_OK=True
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,817 @@
|
||||
--[[----------------------------------------------------------------------------
|
||||
S5ePAnim —— S5 41mm(q63) 压力检测动画 -> Xiaomi Watch S5 eSIM 46mm(p62lte)
|
||||
|
||||
目标固件:miwear.watch.p62lte_v3.112.035_full(480x480,Lua 5.4 宿主)
|
||||
写入目标:ROMFS 分区 /dev/health(卷名 health,sb_size 206,924,736 B)
|
||||
pressure/measure/Measuring1..119.bin
|
||||
119 个槽位全是 464x464 I8 + RLE(usize = 1024 + 464*464 + 1 = 216,321)。
|
||||
负载**逐条等长**:每条写进去的字节数恰好等于原槽位长度(4485 ~ 196538 B),
|
||||
所以 ROMFS 的 inode 表 / 目录项 / 后面所有文件的绝对偏移一个字节都不动。
|
||||
|
||||
源:Xiaomi Watch S5 41mm(q63, v4.101.020)pressure/measure 的 100 帧 480x480。
|
||||
映射:第 k 个槽位(k=1..119) <- q63 第 round((k-1)/(119-1)*(100-1))+1 帧。
|
||||
|
||||
界面按 480x480 设计(tools/s5e_pages.py,本机真 MiSans 渲染 + 真实像素判圆屏切边:
|
||||
0 越界、0 折行)。坐标与设计稿一一对应,预览图不是另画一套。
|
||||
|
||||
两份盘共用同一个 watchface id 462150102(462 = S5 家族):
|
||||
S5ePAnim.face 只装"替换后"的 119 条 + 原厂前 64 B 指纹
|
||||
S5ePAnimRestore.face 只装"原厂"的 119 条 + 替换后前 64 B 指纹
|
||||
装其中一份就顶掉另一份,设备上始终只占一份空间。
|
||||
|
||||
★ 分区键名 vs 设备节点(S4e 那版踩过的坑,别改回去)
|
||||
DEVS 的**键**是 'health',值是设备节点 '/dev/health';运行期才取绝对路径。
|
||||
负载表里存的是**分区内偏移**(整数),没有节点名字段。
|
||||
|
||||
真机已验证的底层能力(沿用 S5->S4 那个表盘):
|
||||
* 宿主不调用 ui.init -> 加载期建界面
|
||||
* SCRIPT_PATH 为空 -> 用 debug.getinfo 反推目录
|
||||
* .face 不解包,资源仍在 resource.bin 里 -> 按偏移读出来用
|
||||
* os.execute / dd 可用;os.execute 的返回值不可靠 -> 一律用内容比对判成败
|
||||
真机**未**验证的:S5 eSIM(p62lte) 的 /dev/health 可写(同源平台 S4 eSIM 上的 dd 流程
|
||||
实测通过)。所以写入前一律先只读预检查、再逐条版本校验,版本对不上就跳过分区剩余记录。
|
||||
------------------------------------------------------------------------------]]
|
||||
|
||||
local lvgl = require("lvgl")
|
||||
|
||||
local W, H = 480, 480
|
||||
local BLK = 4
|
||||
|
||||
--------------------------------------------------------------------- 设备与临时文件
|
||||
local DEVS = { health = "/dev/health" } -- 键名 -> 设备节点(Lua 表里只存键名)
|
||||
local DEV = DEVS.health
|
||||
local VOLUME = "health" -- ROMFS superblock 里的卷名,预检查要逐字节对上
|
||||
local TMPR = "/tmp/s5p_back.bin" -- 回读出来的那一份
|
||||
local TMPB = "/tmp/s5p_blob.bin" -- 待写入的负载(resource.bin 里抠出来的那一份)
|
||||
|
||||
-- 等长覆盖只保证「偏移」512 对齐;RLE 素材的长度常常不是 4 的倍数(如 4485 / 196538),
|
||||
-- 那种记录必须退化成 bs=1(偏移本身对齐,所以 bs=1 也精确),否则会多写相邻文件的字节。
|
||||
local function ddBlk(off, len)
|
||||
if off % BLK == 0 and len % BLK == 0 then return BLK end
|
||||
return 1
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 自身目录
|
||||
local SRC = ""
|
||||
if debug and debug.getinfo then
|
||||
local info = debug.getinfo(1, "S")
|
||||
SRC = (info and info.source) or ""
|
||||
end
|
||||
if type(SRC) ~= "string" then SRC = "" end
|
||||
if SRC:sub(1, 1) == "@" then SRC = SRC:sub(2) end
|
||||
SRC = SRC:gsub("\\", "/")
|
||||
local THIS_DIR = SRC:match("^(.*)/[^/]+$") or ""
|
||||
local SP = THIS_DIR ~= "" and (THIS_DIR .. "/") or ""
|
||||
local PARENT = SP:gsub("[^/]+/$", "")
|
||||
|
||||
local RES = nil
|
||||
do
|
||||
local c = { PARENT .. "resource.bin", SP .. "../resource.bin", SP .. "resource.bin" }
|
||||
for i = 1, #c do
|
||||
local f = io.open(c[i], "rb")
|
||||
if f then f:close(); RES = c[i]; break end
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 资源偏移
|
||||
-- UI 图片 { resource.bin 内偏移, 长度, 名字 } —— 由 tools/s5e_build.py 回填
|
||||
local UIIMG = {
|
||||
__UIIMG__
|
||||
}
|
||||
|
||||
-- 角色:'replace' = 只负责替换(写 q63 的帧);'restore' = 只负责还原(写回原厂字节)
|
||||
-- 两份盘共用同一个 watchface id,所以设备上只会占一份空间(见 README)
|
||||
local ROLE = '__ROLE__'
|
||||
|
||||
-- 负载表 { resource.bin 内偏移, /dev/health 内偏移, 长度 } —— 由脚本回填
|
||||
-- pay = 本盘要写进去的字节
|
||||
-- rb = **另一侧字节前 64 B 的指纹**(只给写入前的版本校验用)
|
||||
-- replace 盘的 rb 是原厂前 64 B;restore 盘的 rb 是替换后前 64 B
|
||||
local PAYTBL = {
|
||||
__PAY__
|
||||
__RB__
|
||||
}
|
||||
|
||||
local function prepImages()
|
||||
local paths = {}
|
||||
if not RES then return paths end
|
||||
local f = io.open(RES, "rb")
|
||||
if not f then return paths end
|
||||
for i = 1, #UIIMG do
|
||||
local e = UIIMG[i]
|
||||
f:seek("set", e[1])
|
||||
local d = f:read(e[2])
|
||||
if d and #d == e[2] then
|
||||
-- 直接写到 /tmp 根下:不能写子目录(目录不存在,且这台机器的 Lua 没有 mkdir)
|
||||
local p = "/tmp/s5p_ui_" .. e[3] .. ".bin"
|
||||
local o = io.open(p, "wb")
|
||||
if o then o:write(d); o:close(); paths[e[3]] = p end
|
||||
end
|
||||
end
|
||||
f:close()
|
||||
return paths
|
||||
end
|
||||
|
||||
local IMG = prepImages()
|
||||
|
||||
--------------------------------------------------------------------- 颜色与字体
|
||||
local C_GRAY, C_WHITE = '#999999', '#ffffff'
|
||||
local C_OK, C_ERR = '#43cf7c', '#d43030'
|
||||
local C_PLATE, C_CIRC = 0x002927, 0x4f4f4f
|
||||
|
||||
local function getFont(name, size)
|
||||
local f = nil
|
||||
pcall(function() f = lvgl.Font(name, size) end)
|
||||
return f
|
||||
end
|
||||
|
||||
local F_CLOCK = getFont('MiSans-Semibold', 29)
|
||||
local F_TITLE = getFont('MiSans-Medium', 31)
|
||||
local F_ITEM = getFont('MiSans-Medium', 29)
|
||||
local F_BODY = getFont('MiSans-Medium', 25)
|
||||
local F_SMALL = getFont('MiSans-Medium', 24) -- 说明文字 / 日志(一行 348px 以内)
|
||||
local F_VER = getFont('MiSans-Medium', 21)
|
||||
|
||||
--------------------------------------------------------------------- 按下高亮
|
||||
-- onPressed / onClicked 是真实的 LVGL 对象方法(就在 onevent 旁边,属性表里可查)
|
||||
local HL = 0x005149 -- 按下高亮色(比底板 0x002927 明显亮)
|
||||
local pressed = nil -- { obj = , t = }
|
||||
local tickCount = 0
|
||||
|
||||
local function setBg(obj, c)
|
||||
pcall(function() obj:set { bg_color = c } end)
|
||||
end
|
||||
|
||||
local function pressRelease()
|
||||
if pressed then
|
||||
setBg(pressed.obj, C_PLATE)
|
||||
pressed = nil
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 基础
|
||||
local ui = {}
|
||||
local built = false
|
||||
local pages = {}
|
||||
local clocks = {}
|
||||
local job = { run = false, list = nil, i = 0, ok = 0, errs = 0, vers = 0,
|
||||
skipRest = false }
|
||||
-- 从哪个入口进确认页:首页 -> 'replace'(本盘的 pay 表就是该角色的字节);
|
||||
-- 帮助页「回滚修改」只提示装另一份盘(本盘不含另一侧的完整负载)
|
||||
local pendingVariant = 'replace'
|
||||
-- 确认页是从哪进来的,返回就回哪:首页 -> 'home';帮助页 -> 'help'
|
||||
local confirmFrom = 'home'
|
||||
|
||||
local function newPage()
|
||||
local p = nil
|
||||
pcall(function()
|
||||
p = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000,
|
||||
bg_opa = 255, border_width = 0, pad_all = 0 })
|
||||
p:clear_flag(lvgl.FLAG.SCROLLABLE)
|
||||
p:add_flag(lvgl.FLAG.EVENT_BUBBLE)
|
||||
end)
|
||||
return p
|
||||
end
|
||||
|
||||
local function show(p)
|
||||
if not p then return end
|
||||
pressRelease() -- 切页时清掉可能残留的高亮
|
||||
for k, v in pairs(pages) do
|
||||
pcall(function() v:add_flag(lvgl.FLAG.HIDDEN) end)
|
||||
end
|
||||
pcall(function() p:clear_flag(lvgl.FLAG.HIDDEN) end)
|
||||
end
|
||||
|
||||
local function bind(obj, fn)
|
||||
if not obj then return end
|
||||
-- 成品样本用的是 onevent(lvgl.EVENT.SHORT_CLICKED, fn)(CLICKED 这个常量
|
||||
-- 在这台机器上不存在),所以按 SHORT_CLICKED -> onClicked -> CLICKED 依次试
|
||||
local ev = lvgl.EVENT
|
||||
if ev and ev.SHORT_CLICKED then
|
||||
if pcall(function() obj:onevent(ev.SHORT_CLICKED, function() fn() end) end) then return end
|
||||
end
|
||||
if pcall(function() obj:onClicked(function() fn() end) end) then return end
|
||||
pcall(function() obj:onevent(ev and ev.CLICKED, fn) end)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 点按
|
||||
-- 点按 = 按下高亮 + 抬起恢复 + 执行动作;带超时兜底(手指滑出时 click 不触发)
|
||||
local function bindTap(obj, onTap)
|
||||
if not obj then return end
|
||||
local ok = pcall(function()
|
||||
obj:onPressed(function()
|
||||
setBg(obj, HL)
|
||||
pressed = { obj = obj, t = tickCount }
|
||||
end)
|
||||
obj:onClicked(function()
|
||||
pressRelease()
|
||||
if onTap then onTap() end
|
||||
end)
|
||||
end)
|
||||
if not ok then bind(obj, onTap) end
|
||||
end
|
||||
|
||||
local function setClickable(o)
|
||||
if not o then return end
|
||||
pcall(function() o:add_flag(lvgl.FLAG.CLICKABLE) end)
|
||||
end
|
||||
|
||||
local function lab(parent, o)
|
||||
o.text_font = o.text_font or F_BODY
|
||||
local l = nil
|
||||
pcall(function() l = parent:Label(o) end)
|
||||
return l
|
||||
end
|
||||
|
||||
-- 时钟:居中不用 text_align(它要的是 lv_text_align_t,传 ALIGN.CENTER 越界会被忽略
|
||||
-- -> 实测变成左对齐)。改为按真字体量出的宽度手工算 x:'10:24' 29px 量得 78
|
||||
local function clockLabel(parent)
|
||||
local l = lab(parent, { x = 201, y = 4, w = 78, h = 46, text = '--:--',
|
||||
text_color = C_GRAY, font_size = 29, text_font = F_CLOCK })
|
||||
clocks[#clocks + 1] = l
|
||||
return l
|
||||
end
|
||||
|
||||
-- 标题:框 y=36 h=46,字号 31 行高 42 -> 从框顶画即可(LVGL 标签从框顶起排)
|
||||
local function pageTitle(parent, text, x, w)
|
||||
return lab(parent, { x = x, y = 36, w = w, h = 46, text = text,
|
||||
text_color = C_WHITE, font_size = 31, text_font = F_TITLE })
|
||||
end
|
||||
|
||||
local function backArrow(parent, x, onTap)
|
||||
local im = nil
|
||||
if IMG.back then
|
||||
pcall(function() im = parent:Image { src = IMG.back, x = x or 167, y = 44 } end)
|
||||
setClickable(im)
|
||||
bind(im, onTap)
|
||||
end
|
||||
return im
|
||||
end
|
||||
|
||||
-- 列表项:底板(整块可点) + 圆形遮罩 + 图标 + 标题
|
||||
-- pad_all=0 是关键:LVGL 主题会给 Object 加内边距,不清零子元素会整体右下偏移。
|
||||
-- radius=40 也不是审美选择:y=88 那行离圆心 152px,圆屏在那里的弦半宽只有 185.8,
|
||||
-- 圆角不够大时四个角会越过圆边(tools/s5e_check_clip.py 会量出来)。
|
||||
local function listItem(parent, y, iconkey, title, onTap)
|
||||
local plate = nil
|
||||
pcall(function()
|
||||
plate = parent:Object { x = 50, y = y, w = 379, h = 91, pad_all = 0,
|
||||
bg_color = C_PLATE, border_width = 0, radius = 40 }
|
||||
end)
|
||||
if plate then
|
||||
if onTap then
|
||||
setClickable(plate)
|
||||
bindTap(plate, onTap)
|
||||
end
|
||||
pcall(function()
|
||||
plate:Object { x = 21, y = 20, w = 52, h = 52,
|
||||
bg_color = C_CIRC, border_width = 0, radius = 26 }
|
||||
end)
|
||||
if IMG[iconkey] then
|
||||
pcall(function() plate:Image { src = IMG[iconkey], x = 31, y = 30 } end)
|
||||
end
|
||||
-- 标题 29px 行高 40,框高 42 -> 从框顶画就是垂直居中
|
||||
lab(plate, { x = 82, y = 26, w = 214, h = 42, text = title,
|
||||
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
|
||||
end
|
||||
return plate
|
||||
end
|
||||
|
||||
-- 大按钮 258x82 圆角 41(y=376):圆角把四角收进圆内(本机像素判据实测 0 越界)
|
||||
local function bigButton(parent, text)
|
||||
local b = nil
|
||||
pcall(function()
|
||||
b = parent:Object { x = 111, y = 376, w = 258, h = 82, pad_all = 0,
|
||||
bg_color = C_PLATE, border_width = 0, radius = 41 }
|
||||
end)
|
||||
if b then setClickable(b) end
|
||||
-- 按钮文字:框 (71,22,126,40) 相对底板;'确认替换'/'确认还原'/'重启手表' 29px 量得 116
|
||||
-- -> 居中 x = (258-116)/2 = 71
|
||||
lab(b, { x = 71, y = 22, w = 126, h = 40, text = text,
|
||||
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
|
||||
return b
|
||||
end
|
||||
|
||||
local function clockTick()
|
||||
local t = '--:--'
|
||||
if os and type(os.date) == "function" then
|
||||
local ok, s = pcall(os.date, "%H:%M")
|
||||
if ok and type(s) == "string" then t = s end
|
||||
end
|
||||
for i = 1, #clocks do
|
||||
pcall(function() clocks[i]:set { text = t } end)
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 外壳
|
||||
local function slurp(p, n)
|
||||
local f = io.open(p, "rb")
|
||||
if not f then return nil end
|
||||
local d = f:read(n or "*a")
|
||||
f:close()
|
||||
return d
|
||||
end
|
||||
|
||||
local function sh(cmd)
|
||||
if type(os.execute) ~= "function" then return -1 end
|
||||
local a, b, c = os.execute(cmd)
|
||||
if type(a) == "number" then return a end
|
||||
if a == true then return 0 end
|
||||
if type(c) == "number" then return c end
|
||||
if type(b) == "number" then return b end
|
||||
return -1
|
||||
end
|
||||
|
||||
-- 从 resource.bin 里取一段(负载 / 另一侧指纹)
|
||||
local function slice(off, len)
|
||||
if not RES then return nil end
|
||||
local f = io.open(RES, "rb")
|
||||
if not f then return nil end
|
||||
f:seek("set", off)
|
||||
local d = f:read(len)
|
||||
f:close()
|
||||
return d
|
||||
end
|
||||
|
||||
-- 偏移都是 512 对齐,RLE 素材的长度却常常不是 4/512 的倍数(4485 / 196538 …)。
|
||||
-- 老写法对这种长度只能 bs=1 -> 一条 19 万字节要 38 万次单字节读写,慢到偶发失败
|
||||
-- (S4e 真机上就是这么翻车的)。这里把它切成 ≤3 段:bs=512 的大块 + bs=4 的次块 +
|
||||
-- 最后 1~3 字节 bs=1,syscall 直接降两个数量级。
|
||||
local BIG = 512
|
||||
|
||||
local function segs(off, len)
|
||||
local out = {}
|
||||
if off % BIG == 0 then
|
||||
local head = len - (len % BIG)
|
||||
if head > 0 then out[#out + 1] = { BIG, 0, head } end
|
||||
local rest = len - head
|
||||
local t4 = rest - (rest % 4)
|
||||
if t4 > 0 then out[#out + 1] = { 4, head, t4 } end
|
||||
if rest - t4 > 0 then out[#out + 1] = { 1, head + t4, rest - t4 } end
|
||||
else
|
||||
out[1] = { 1, 0, len }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 读设备上某偏移处的 len 字节(只读)
|
||||
local function ddRead(off, len)
|
||||
local parts = {}
|
||||
for _, s in ipairs(segs(off, len)) do
|
||||
local b, rel, n = s[1], s[2], s[3]
|
||||
if os.remove then pcall(os.remove, TMPR) end
|
||||
sh(string.format("dd if=%s of=%s bs=%d skip=%d count=%d",
|
||||
DEV, TMPR, b, (off + rel) // b, n // b))
|
||||
local d = slurp(TMPR, n)
|
||||
if not d or #d ~= n then return nil end
|
||||
parts[#parts + 1] = d
|
||||
end
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
-- 每段单独一个临时文件:这样 dd 只需要 seek(输出偏移),不依赖输入侧的 skip
|
||||
local function ddWrite(off, len, data)
|
||||
for _, s in ipairs(segs(off, len)) do
|
||||
local b, rel, n = s[1], s[2], s[3]
|
||||
local o = io.open(TMPB, "wb")
|
||||
if not o then return false end
|
||||
o:write(data:sub(rel + 1, rel + n))
|
||||
o:close()
|
||||
sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d",
|
||||
TMPB, DEV, b, (off + rel) // b, n // b))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 预检查
|
||||
local desc1 = nil
|
||||
|
||||
local function setDesc1(txt, color)
|
||||
if not desc1 then return end
|
||||
pcall(function() desc1:set { text = txt, text_color = color } end)
|
||||
end
|
||||
|
||||
-- 同一条记录,两边各自叫什么:replace 盘的 pay 是"改后"、rb 是"原厂"
|
||||
local function sideName(which)
|
||||
if which == 'mine' then
|
||||
return (ROLE == 'restore') and '原厂' or '已替换'
|
||||
end
|
||||
return (ROLE == 'restore') and '已替换' or '原厂'
|
||||
end
|
||||
|
||||
local function doCheck()
|
||||
if not RES then
|
||||
setDesc1('找不到 resource.bin,无法操作。', C_ERR)
|
||||
return false
|
||||
end
|
||||
local list = PAYTBL.pay or {}
|
||||
if #list == 0 then
|
||||
setDesc1('预检查失败:负载表为空。', C_ERR)
|
||||
return false
|
||||
end
|
||||
local h = ddRead(0, 48)
|
||||
if not h or #h < 48 then
|
||||
setDesc1('预检查失败:设备节点读不到。', C_ERR)
|
||||
return false
|
||||
end
|
||||
if h:sub(1, 8) ~= "-rom1fs-" then
|
||||
setDesc1('预检查失败:设备节点读不到,\n或分区头不是 ROMFS。', C_ERR)
|
||||
return false
|
||||
end
|
||||
local z = h:find("\0", 17, true)
|
||||
local vol = h:sub(17, (z or 33) - 1)
|
||||
if vol ~= VOLUME then
|
||||
setDesc1('预检查失败:卷名是 ' .. vol .. '。', C_ERR)
|
||||
return false
|
||||
end
|
||||
-- superblock 的 size 是**大端**(romfs 规范:__be32),所以这里必须用 ">I4"。
|
||||
-- (S4e 那版写成 "<I4",读出来是个天文数字 -> 越界判断形同虚设;这里修正。)
|
||||
local size = string.unpack(">I4", h, 9)
|
||||
local bad, total = 0, 0
|
||||
for i = 1, #list do
|
||||
local r = list[i]
|
||||
local b = ddBlk(r[2], r[3])
|
||||
if (r[2] % b) ~= 0 or (r[3] % b) ~= 0 then bad = bad + 1 end
|
||||
if (r[2] + r[3]) > size then bad = bad + 1 end
|
||||
total = total + r[3]
|
||||
end
|
||||
if bad > 0 then
|
||||
setDesc1(string.format('预检查失败:%d 项越界或未对齐。', bad), C_ERR)
|
||||
return false
|
||||
end
|
||||
-- 顺带确认 resource.bin 第一条能读出来
|
||||
local probe = slice(list[1][1], list[1][3])
|
||||
if not probe or #probe ~= list[1][3] then
|
||||
setDesc1('预检查失败:resource.bin 读取异常。', C_ERR)
|
||||
return false
|
||||
end
|
||||
-- 抽样版本校验(只读):首 / 中 / 尾三条,比对设备上真实的 64 字节与本盘的
|
||||
-- 「要写进去的字节」或「另一侧指纹」—— 能确认负载偏移就是给这一版固件算的。
|
||||
local marks = { 1, math.max(1, #list // 2), #list }
|
||||
local samp, okAll = {}, true
|
||||
for i = 1, #marks do
|
||||
local k = marks[i]
|
||||
local rec, oth = list[k], (PAYTBL.rb or {})[k]
|
||||
local got = ddRead(rec[2], 64)
|
||||
local mine = slice(rec[1], 64)
|
||||
local other = oth and slice(oth[1], 64) or nil
|
||||
if not got or #got < 64 then
|
||||
samp[#samp + 1] = '×'
|
||||
okAll = false
|
||||
elseif mine and got == mine then
|
||||
samp[#samp + 1] = sideName('mine') .. '✓'
|
||||
elseif other and got == other then
|
||||
samp[#samp + 1] = sideName('other') .. '✓'
|
||||
else
|
||||
samp[#samp + 1] = '抽样×'
|
||||
okAll = false
|
||||
end
|
||||
end
|
||||
local head = string.format('预检查通过 | %d 条 | %.2fMB', #list, total / 1048576)
|
||||
setDesc1(head .. '\n' .. VOLUME .. ' 分区可读。' .. table.concat(samp, ' '),
|
||||
okAll and C_OK or C_ERR)
|
||||
return okAll
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 替换 / 还原
|
||||
local logLabel = nil
|
||||
local rebootBtn = nil
|
||||
local runningTitle = nil
|
||||
local logLines = {}
|
||||
|
||||
local function logOut(s)
|
||||
logLines[#logLines + 1] = s
|
||||
-- 日志框 h=280、24px 行高 34 -> 最多 8 行;留 8 行保证最后一行完整可见
|
||||
while #logLines > 8 do table.remove(logLines, 1) end
|
||||
if logLabel then
|
||||
pcall(function() logLabel:set { text = table.concat(logLines, "\n") } end)
|
||||
end
|
||||
end
|
||||
|
||||
local function showReboot()
|
||||
if rebootBtn then pcall(function() rebootBtn:clear_flag(lvgl.FLAG.HIDDEN) end) end
|
||||
end
|
||||
|
||||
-- 一条记录三步:① 版本校验(设备上那段必须等于"本盘要写的"或"另一侧"的前 64 B)
|
||||
-- ② 写入 ③ 整段回读比对
|
||||
-- 版本两边都不是 -> 'ver',并且**跳过 /dev/health 剩余记录**(换固件后最安全的行为:
|
||||
-- 绝不把字节写进别人的文件里)。os.execute 的返回值在这台机器上不可靠,所以判据
|
||||
-- 一律是内容比对。
|
||||
local function writeOne(rec, want)
|
||||
local data = slice(rec[1], rec[3])
|
||||
if not data or #data ~= rec[3] then return 'err' end
|
||||
local n = 64
|
||||
if want then
|
||||
if want[3] < n then n = want[3] end
|
||||
local other = slice(want[1], n)
|
||||
local cur = ddRead(rec[2], n)
|
||||
if not cur or #cur ~= n or not other or #other ~= n then return 'err' end
|
||||
if cur ~= data:sub(1, n) and cur ~= other then return 'ver' end
|
||||
end
|
||||
local ok, lastBack = false, nil
|
||||
for attempt = 1, 3 do
|
||||
if not ddWrite(rec[2], rec[3], data) then return 'err' end
|
||||
sh("sync")
|
||||
lastBack = ddRead(rec[2], rec[3])
|
||||
if lastBack and #lastBack == rec[3] and lastBack == data then ok = true break end
|
||||
logOut(string.format('> 第 %d 条校验不过,重试 %d/3', job.i, attempt))
|
||||
end
|
||||
if not ok then
|
||||
-- 计划 B:常规「分段写」3 次不过时,改成整条一次 dd(bs=4 或 bs=1)。
|
||||
-- 有些驱动对「同一区域被拆成多次小写」比「一次大写」更敏感,换个写法常常就过了。
|
||||
local b = (rec[3] % 4 == 0) and 4 or 1
|
||||
local o = io.open(TMPB, "wb")
|
||||
if o then
|
||||
o:write(data)
|
||||
o:close()
|
||||
sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d",
|
||||
TMPB, DEV, b, rec[2] // b, rec[3] // b))
|
||||
sh("sync")
|
||||
local back2 = ddRead(rec[2], rec[3])
|
||||
if back2 and #back2 == rec[3] and back2 == data then
|
||||
ok = true
|
||||
logOut(string.format('> 第 %d 条改用整条 bs=%d 写入后校验通过', job.i, b))
|
||||
end
|
||||
end
|
||||
end
|
||||
if not ok then
|
||||
-- 诊断:把「哪一条 / 目标偏移 / 长度 / 读回多少字节 / 第一个不同的字节」打出来
|
||||
local why = '读回 nil'
|
||||
if lastBack then
|
||||
if #lastBack ~= rec[3] then
|
||||
why = string.format('读回 %d 字节', #lastBack)
|
||||
else
|
||||
why = '内容全同?'
|
||||
for i = 1, #lastBack do
|
||||
if lastBack:byte(i) ~= data:byte(i) then
|
||||
why = string.format('首差@%d 得%02X 期%02X', i - 1, lastBack:byte(i), data:byte(i))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
logOut(string.format('> 失败 #%d off=0x%X len=%d %s', job.i, rec[2], rec[3], why))
|
||||
return 'err'
|
||||
end
|
||||
return 'ok'
|
||||
end
|
||||
|
||||
local function startJob(variant)
|
||||
if job.run then return end
|
||||
local list = PAYTBL.pay or {}
|
||||
if #list == 0 then
|
||||
setDesc1('负载表为空,已中止。', C_ERR)
|
||||
return
|
||||
end
|
||||
job.run, job.list, job.i, job.ok, job.errs, job.vers = true, list, 0, 0, 0, 0
|
||||
job.skipRest = false
|
||||
logLines = {}
|
||||
logOut(ROLE == 'restore' and '> 开始还原' or '> 开始替换')
|
||||
logOut(string.format('> %s 119 帧 / 共 %d 条', VOLUME, #list))
|
||||
logOut('> 读取 resource.bin')
|
||||
if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end
|
||||
if runningTitle then
|
||||
pcall(function()
|
||||
runningTitle:set { text = (ROLE == 'restore') and '正在还原' or '正在替换' }
|
||||
end)
|
||||
end
|
||||
show(pages.running)
|
||||
end
|
||||
|
||||
local function tick()
|
||||
tickCount = tickCount + 1
|
||||
-- 兜底:按下超过 1.6s 没收到 click(手指滑出等)就恢复高亮
|
||||
if pressed and (tickCount - pressed.t) > 40 then pressRelease() end
|
||||
if not job.run or not job.list then return end
|
||||
job.i = job.i + 1
|
||||
local n = #job.list
|
||||
if job.i > n then
|
||||
job.run = false
|
||||
logOut(ROLE == 'restore' and '> 还原完毕,请重启手表。'
|
||||
or '> 替换完毕,请重启手表。')
|
||||
logOut(string.format('> 共 %d 条,成功 %d 失败 %d', n, job.ok, job.errs))
|
||||
if job.vers > 0 then
|
||||
logOut(string.format('> 版本不符跳过 %d 条', job.vers))
|
||||
logOut('> 请勿重启,先装回匹配版本。')
|
||||
else
|
||||
logOut('> 若发现问题,请回滚修改。')
|
||||
end
|
||||
showReboot()
|
||||
return
|
||||
end
|
||||
if job.skipRest then
|
||||
job.vers = job.vers + 1
|
||||
else
|
||||
local rb = PAYTBL.rb and PAYTBL.rb[job.i]
|
||||
local r = writeOne(job.list[job.i], rb)
|
||||
if r == 'ver' then
|
||||
job.vers = job.vers + 1
|
||||
job.skipRest = true -- 版本不符:跳过 /dev/health 剩余记录
|
||||
logOut('> /dev/health 版本不符,已跳过剩余记录')
|
||||
elseif r == 'ok' then
|
||||
job.ok = job.ok + 1
|
||||
else
|
||||
job.errs = job.errs + 1
|
||||
end
|
||||
end
|
||||
if job.i % 10 == 0 or job.i == n then
|
||||
logOut(string.format('> 写入 %d/%d 成功 %d 失败 %d', job.i, n, job.ok, job.errs))
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 页面
|
||||
local restore = (ROLE == 'restore')
|
||||
|
||||
local function buildHome()
|
||||
local p = newPage()
|
||||
if not p then return nil end
|
||||
clockLabel(p)
|
||||
-- 盘内标题 = 显示名(不再用中文描述名);x 按真 MiSans 31px 量出的宽度居中:
|
||||
-- 'S5e Pt.2' = 125 -> x = (480-125)/2 = 177.5 -> 177
|
||||
-- 'S5e Pt.2 · 还原' = 225 -> x = (480-225)/2 = 127.5 -> 127
|
||||
if restore then
|
||||
pageTitle(p, 'S5e Pt.2 · 还原', 127, 227)
|
||||
else
|
||||
pageTitle(p, 'S5e Pt.2', 177, 127)
|
||||
end
|
||||
listItem(p, 88, 'ic_help', '快速帮助', function() show(pages.help) end)
|
||||
listItem(p, 186, 'ic_replace', restore and '开始还原' or '开始替换', function()
|
||||
pendingVariant = 'replace'
|
||||
confirmFrom = 'home'
|
||||
show(pages.confirm)
|
||||
end)
|
||||
listItem(p, 285, 'ic_about', '关于', function() show(pages.about) end)
|
||||
return p
|
||||
end
|
||||
|
||||
local function buildHelp()
|
||||
local p = newPage()
|
||||
if not p then return nil end
|
||||
clockLabel(p)
|
||||
backArrow(p, 167, function() show(pages.home) end)
|
||||
local t = pageTitle(p, '快速帮助', 188, 124)
|
||||
setClickable(t)
|
||||
bind(t, function() show(pages.home) end)
|
||||
|
||||
listItem(p, 88, 'ic_check', '环境预检查', function() doCheck() end)
|
||||
desc1 = lab(p, { x = 67, y = 182, w = 348, h = 76,
|
||||
text = '检查 health 分区与负载完整性。',
|
||||
text_color = C_GRAY, font_size = 24, text_font = F_SMALL })
|
||||
|
||||
-- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责
|
||||
listItem(p, 264, 'ic_rollback', '回滚修改', function()
|
||||
if restore then
|
||||
setDesc1('本盘负责还原,换回动画\n请装 S5e Pt.2。', C_GRAY)
|
||||
else
|
||||
setDesc1('本盘只负责替换,请装\nS5e Pt.2 · 还原。', C_GRAY)
|
||||
end
|
||||
end)
|
||||
lab(p, { x = 67, y = 360, w = 348, h = 42,
|
||||
text = restore and '把 119 帧动画换回原厂样式。'
|
||||
or '恢复 119 帧动画的原厂样式。',
|
||||
text_color = C_GRAY, font_size = 25, text_font = F_BODY })
|
||||
return p
|
||||
end
|
||||
|
||||
local function buildConfirm()
|
||||
local p = newPage()
|
||||
if not p then return nil end
|
||||
clockLabel(p)
|
||||
-- 确认页的返回:回到进来时的那个页面(首页 或 快速帮助)
|
||||
local function goBack() show(pages[confirmFrom] or pages.home) end
|
||||
backArrow(p, 167, goBack)
|
||||
local t = pageTitle(p, restore and '开始还原' or '开始替换', 188, 124)
|
||||
setClickable(t)
|
||||
bind(t, goBack)
|
||||
|
||||
-- 多色文本**不能**用 recolor:AP 里那几处 recolor 是图片重着色(bg_img_recolor)
|
||||
-- 和表盘编辑器的主题重着色,不是文本属性;实测标记会被原样显示出来。
|
||||
-- 改为拆成多个标签 + 手工算 x(宽度用真 MiSans 25px 量出),框宽 ≥ 文本宽
|
||||
-- -> 保证不折行(折行会让手工算 x 的片段错位),并且整框都在圆内。
|
||||
-- 型号拆两行:圆屏在 y=243 处的弦半宽只有 ~190px,而 'Xiaomi Watch S5 eSIM 46mm'
|
||||
-- 量得 367px,放一行会被圆边切掉(tools/s5e_check_clip.py 抓过这个)。
|
||||
local WARN = {
|
||||
{ 60, 95, 360, 34, '替换压力检测动画之前,', C_GRAY },
|
||||
{ 60, 129, 360, 34, '请先确认自己的系统版本号为 ', C_GRAY },
|
||||
{ 304, 129, 120, 34, '3.112.035', C_WHITE },
|
||||
{ 422, 129, 40, 34, '!', C_GRAY },
|
||||
{ 64, 195, 360, 34, '请确认自己的手表型号为', C_GRAY },
|
||||
{ 64, 229, 360, 34, 'Xiaomi Watch S5', C_WHITE },
|
||||
{ 64, 263, 360, 34, 'eSIM 46mm(p62lte)', C_WHITE },
|
||||
{ 60, 341, 360, 34, '搞机有风险,变砖后果自负。', C_GRAY },
|
||||
}
|
||||
for i = 1, #WARN do
|
||||
local w = WARN[i]
|
||||
lab(p, { x = w[1], y = w[2], w = w[3], h = w[4], text = w[5],
|
||||
text_color = w[6], font_size = 25, text_font = F_BODY })
|
||||
end
|
||||
|
||||
local b = bigButton(p, restore and '确认还原' or '确认替换')
|
||||
bindTap(b, function() startJob(pendingVariant) end)
|
||||
return p
|
||||
end
|
||||
|
||||
local function buildAbout()
|
||||
local p = newPage()
|
||||
if not p then return nil end
|
||||
clockLabel(p)
|
||||
backArrow(p, 198, function() show(pages.home) end)
|
||||
local t = pageTitle(p, '关于', 220, 62)
|
||||
setClickable(t)
|
||||
bind(t, function() show(pages.home) end)
|
||||
|
||||
-- 信息卡:底板 + 圆形 logo + 大标题 + 版本号
|
||||
pcall(function()
|
||||
p:Object { x = 54, y = 81, w = 373, h = 210, pad_all = 0,
|
||||
bg_color = C_PLATE, border_width = 0, radius = 31 }
|
||||
end)
|
||||
if IMG.logo then
|
||||
pcall(function() p:Image { src = IMG.logo, x = 200, y = 111 } end)
|
||||
end
|
||||
-- 卡内 29px 量得:'S5e Pt.2' = 118 -> x = 54 + (373-118)/2 = 181.5 -> 181
|
||||
-- 'S5e Pt.2 · 还原' = 211 -> x = 54 + (373-211)/2 = 135(整)
|
||||
if restore then
|
||||
lab(p, { x = 135, y = 200, w = 213, h = 42, text = 'S5e Pt.2 · 还原',
|
||||
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
|
||||
else
|
||||
lab(p, { x = 181, y = 200, w = 120, h = 42, text = 'S5e Pt.2',
|
||||
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
|
||||
end
|
||||
-- '1.0.0' 21px 量得 49 -> 居中 x = 54 + (373-49)/2 = 216
|
||||
lab(p, { x = 222, y = 237, w = 49, h = 28, text = '1.0.0',
|
||||
text_color = C_GRAY, font_size = 21, text_font = F_VER })
|
||||
|
||||
-- QQ 联系方式:结构与列表项一致(无动作,所以不给它绑点击)
|
||||
listItem(p, 300, 'ic_qq', '732681995', nil)
|
||||
return p
|
||||
end
|
||||
|
||||
local function buildRunning()
|
||||
local p = newPage()
|
||||
if not p then return nil end
|
||||
clockLabel(p)
|
||||
-- 本页没有返回图标(规格:顶栏同首页),所以标题要屏幕居中:
|
||||
-- '正在替换'/'正在还原' 31px 量得 124 -> x = 240 - 124/2 = 178
|
||||
runningTitle = pageTitle(p, restore and '正在还原' or '正在替换', 178, 124)
|
||||
logLabel = lab(p, { x = 67, y = 95, w = 348, h = 280, text = '',
|
||||
text_color = C_WHITE, font_size = 24, text_font = F_SMALL })
|
||||
rebootBtn = bigButton(p, '重启手表')
|
||||
-- 替换过程中不显示,替换完再显示
|
||||
if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end
|
||||
bindTap(rebootBtn, function()
|
||||
logOut('> 正在重启…')
|
||||
sh("sync")
|
||||
sh("reboot")
|
||||
end)
|
||||
return p
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------- 组装
|
||||
local function build()
|
||||
if built then return end
|
||||
built = true
|
||||
pages.home = buildHome()
|
||||
pages.help = buildHelp()
|
||||
pages.confirm = buildConfirm()
|
||||
pages.about = buildAbout()
|
||||
pages.running = buildRunning()
|
||||
if pages.home then show(pages.home) end
|
||||
clockTick()
|
||||
pcall(function()
|
||||
ui.timer = lvgl.Timer { period = 40, repeat_count = -1, cb = function() tick() end }
|
||||
if ui.timer and ui.timer.resume then ui.timer:resume() end
|
||||
end)
|
||||
pcall(function()
|
||||
ui.clockTimer = lvgl.Timer { period = 1000, repeat_count = -1,
|
||||
cb = function() clockTick() end }
|
||||
if ui.clockTimer and ui.clockTimer.resume then ui.clockTimer:resume() end
|
||||
end)
|
||||
end
|
||||
|
||||
local okbuild, errbuild = pcall(build)
|
||||
if not okbuild then
|
||||
pcall(function()
|
||||
local r = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000,
|
||||
bg_opa = 255, border_width = 0 })
|
||||
r:Label { x = 20, y = 20, w = W - 40, h = H - 40,
|
||||
text = 'BUILD FAILED:\n' .. tostring(errbuild),
|
||||
text_color = '#ffffff', font_size = 13, text_font = F_BODY }
|
||||
end)
|
||||
end
|
||||
|
||||
ui.init = function()
|
||||
pcall(build)
|
||||
return ui.root
|
||||
end
|
||||
|
||||
function pageOnPause()
|
||||
if ui.timer then pcall(function() ui.timer:pause() end) end
|
||||
if ui.clockTimer then pcall(function() ui.clockTimer:pause() end) end
|
||||
end
|
||||
|
||||
function pageOnResume()
|
||||
if ui.timer then pcall(function() ui.timer:resume() end) end
|
||||
if ui.clockTimer then pcall(function() ui.clockTimer:resume() end) end
|
||||
end
|
||||
|
||||
return ui
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Build / apply a system-app launcher-icon replacement for a Vela `vela_app.bin`.
|
||||
|
||||
Why "same length" is the whole ball game
|
||||
----------------------------------------
|
||||
ROMFS packs superblock -> inode -> name -> file data strictly back to back, and
|
||||
every `next` / `spec` pointer is an ABSOLUTE offset. If a replacement file
|
||||
changes length by even one byte, every later offset shifts and the image is
|
||||
corrupt. So a replacement must encode to EXACTLY the original byte length:
|
||||
|
||||
* source PNG == original dimensions -> encode, lengths match, done.
|
||||
* source PNG != original dimensions -> two options:
|
||||
(default) resize to the original dimensions (icon renders identically)
|
||||
--pad keep the source dimensions, zero-pad the container to the
|
||||
original length (renders smaller unless the launcher scales)
|
||||
|
||||
Usage:
|
||||
iconpack.py list <vela_app.bin>
|
||||
iconpack.py make <vela_app.bin> <pngdir> <outdir> [--pad] [--patched]
|
||||
iconpack.py apply <vela_app.bin> <patch.bin> <out.bin>
|
||||
"""
|
||||
import os, sys, json, struct, hashlib, shutil
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from romfs import Romfs, img_header
|
||||
from lvgl_bin import rle_compress, pad_rle
|
||||
from PIL import Image
|
||||
|
||||
CF_ARGB8888, CF_I8, MAGIC = 0x10, 0x0a, 0x19
|
||||
FMT = {CF_ARGB8888: 'ARGB8888', CF_I8: 'I8'}
|
||||
RESAMPLE = Image.LANCZOS
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- encoders
|
||||
def encode_argb8888(img):
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
out = bytearray()
|
||||
for y in range(h):
|
||||
row = bytearray()
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
row += bytes((b, g, r, a))
|
||||
out += row
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_i8(img, alpha_levels=16, sharpen_up=0.0):
|
||||
"""I8 encoder with an ALPHA-AWARE palette.
|
||||
|
||||
The naive version quantised RGB only and then stored one *averaged* alpha
|
||||
per palette entry. Any colour shared by an opaque body pixel and a
|
||||
semi-transparent edge pixel collapsed to a mid alpha, and fully transparent
|
||||
pixels inherited that same entry -- so the transparent margin became faintly
|
||||
visible (measured: perpetual_calendar haze 0.000 -> 1.000, i.e. a grey veil
|
||||
around the whole icon).
|
||||
|
||||
Here the palette is built over (quantised RGB, quantised alpha) PAIRS, so a
|
||||
colour can own several entries at different alphas and alpha 0 stays alpha 0.
|
||||
"""
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
src = rgba.load()
|
||||
|
||||
# average colour of the visible pixels: transparent pixels must not invent
|
||||
# their own colour clusters
|
||||
rs = gs = bs = n = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = src[x, y]
|
||||
if a > 0:
|
||||
rs += r; gs += g; bs += b; n += 1
|
||||
avg = (rs // n, gs // n, bs // n) if n else (0, 0, 0)
|
||||
|
||||
step = max(1, 256 // max(1, alpha_levels))
|
||||
flat = Image.new('RGB', (w, h))
|
||||
fp = flat.load()
|
||||
qa = [[0] * w for _ in range(h)]
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = src[x, y]
|
||||
fp[x, y] = (r, g, b) if a > 0 else avg
|
||||
q = int(round(a / float(step))) * step
|
||||
qa[y][x] = 255 if q > 255 else q
|
||||
|
||||
p = flat.quantize(colors=256, method=Image.MEDIANCUT)
|
||||
pal_rgb = p.getpalette()[:768]
|
||||
idx = p.tobytes()
|
||||
|
||||
cnt = {}
|
||||
for y in range(h):
|
||||
row = y * w
|
||||
for x in range(w):
|
||||
k = (idx[row + x], qa[y][x])
|
||||
cnt[k] = cnt.get(k, 0) + 1
|
||||
keep = sorted(cnt.items(), key=lambda kv: -kv[1])[:256]
|
||||
|
||||
pal = bytearray()
|
||||
remap = {}
|
||||
by_rgb = {}
|
||||
for slot, ((pi, al), _c) in enumerate(keep):
|
||||
r = pal_rgb[pi * 3] if pi * 3 + 2 < len(pal_rgb) else 0
|
||||
g = pal_rgb[pi * 3 + 1] if pi * 3 + 2 < len(pal_rgb) else 0
|
||||
b = pal_rgb[pi * 3 + 2] if pi * 3 + 2 < len(pal_rgb) else 0
|
||||
pal += bytes((b, g, r, al))
|
||||
remap[(pi, al)] = slot
|
||||
by_rgb.setdefault(pi, []).append((al, slot))
|
||||
removed = 256 - len(keep)
|
||||
if removed > 0:
|
||||
pal += b'\x00\x00\x00\x00' * removed
|
||||
for pi in by_rgb:
|
||||
by_rgb[pi].sort()
|
||||
|
||||
out = bytearray(w * h)
|
||||
for y in range(h):
|
||||
row = y * w
|
||||
for x in range(w):
|
||||
pi = idx[row + x]
|
||||
al = qa[y][x]
|
||||
s = remap.get((pi, al))
|
||||
if s is None:
|
||||
cand = by_rgb.get(pi)
|
||||
if cand:
|
||||
s = min(cand, key=lambda t: abs(t[0] - al))[1]
|
||||
else:
|
||||
s = 0
|
||||
out[row + x] = s
|
||||
return bytes(pal) + bytes(out)
|
||||
|
||||
|
||||
def encode_i8_palette(img, pal_bgra, alpha_tol=12):
|
||||
"""Quantise to an EXISTING 256-entry BGRA palette (as stored in an I8 blob).
|
||||
|
||||
Reusing the source icon's own palette preserves the artwork's gradient shades
|
||||
and its anti-aliased edge levels. A freshly clustered palette cannot: the
|
||||
plate gradient eats the 256 entries, the edge collapses to a few alpha steps
|
||||
and dropped pixels land on wrong entries -- measured on remote_camera as a
|
||||
jagged plate rim plus stray coloured speckles.
|
||||
|
||||
The search is constrained by alpha so that
|
||||
* fully transparent source pixels can only pick transparent entries
|
||||
(no veil, no speckles), and
|
||||
* fully opaque source pixels can only pick opaque entries
|
||||
(the body never goes translucent).
|
||||
"""
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
|
||||
ent = [(pal_bgra[i * 4 + 2], pal_bgra[i * 4 + 1],
|
||||
pal_bgra[i * 4], pal_bgra[i * 4 + 3]) for i in range(256)]
|
||||
clear = [i for i, e in enumerate(ent) if e[3] == 0]
|
||||
if not clear: # guarantee a transparent entry exists
|
||||
worst = min(range(256), key=lambda i: ent[i][3])
|
||||
ent[worst] = (0, 0, 0, 0)
|
||||
clear = [worst]
|
||||
solid = [i for i, e in enumerate(ent) if e[3] >= 248]
|
||||
by_alpha = {}
|
||||
for i, e in enumerate(ent):
|
||||
by_alpha.setdefault(e[3], []).append(i)
|
||||
|
||||
def premul(e):
|
||||
r, g, b, a = e
|
||||
return (r * a // 255, g * a // 255, b * a // 255, a)
|
||||
|
||||
P = [premul(e) for e in ent]
|
||||
cache = {}
|
||||
out = bytearray(w * h)
|
||||
for y in range(h):
|
||||
row = y * w
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
key = (r >> 2, g >> 2, b >> 2, a >> 3)
|
||||
s = cache.get(key)
|
||||
if s is None:
|
||||
if a == 0:
|
||||
cand = clear
|
||||
elif a >= 248 and solid:
|
||||
cand = solid
|
||||
else:
|
||||
cand = []
|
||||
for da in range(0, alpha_tol + 1):
|
||||
cand += by_alpha.get(a + da, ()) if a + da <= 255 else ()
|
||||
if da:
|
||||
cand += by_alpha.get(a - da, ()) if a - da >= 0 else ()
|
||||
if not cand:
|
||||
cand = range(256)
|
||||
pr, pg, pb, pa = premul((r, g, b, a))
|
||||
best, bd = cand[0], None
|
||||
for i in cand:
|
||||
q = P[i]
|
||||
d = ((pr - q[0]) ** 2 + (pg - q[1]) ** 2 + (pb - q[2]) ** 2
|
||||
+ 2 * (pa - q[3]) ** 2)
|
||||
if bd is None or d < bd:
|
||||
bd, best = d, i
|
||||
s = best
|
||||
cache[key] = s
|
||||
out[row + x] = s
|
||||
|
||||
pal = bytearray()
|
||||
for (r, g, b, a) in ent:
|
||||
pal += bytes((b, g, r, a))
|
||||
return bytes(pal) + bytes(out)
|
||||
|
||||
|
||||
def encode(img, cf, w, h, flags=0, pad_to=None, palette=None):
|
||||
if cf == CF_ARGB8888:
|
||||
body = encode_argb8888(img)
|
||||
elif palette is not None:
|
||||
body = encode_i8_palette(img, palette)
|
||||
else:
|
||||
body = encode_i8(img)
|
||||
stride = w * (4 if cf == CF_ARGB8888 else 1)
|
||||
blob = struct.pack('<BBHHHHH', MAGIC, cf, flags, w, h, stride, 0) + body
|
||||
if pad_to is not None:
|
||||
if len(blob) > pad_to:
|
||||
raise ValueError(f'encoded {len(blob)} > slot {pad_to}')
|
||||
blob += b'\x00' * (pad_to - len(blob))
|
||||
return blob
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- helpers
|
||||
def raw_i8_strided(img, stride, colors=256):
|
||||
"""I8 像素流:1024 B 调色板(BGRA)+ stride*h 个索引(行尾按 header 的 stride 补 0)。
|
||||
|
||||
RLE 槽位专用:用 FASTOCTREE 自适应减色 + 关闭抖动 —— 颜色越少、相邻像素越容易出现
|
||||
长游程,RLE 流就越短(这是能不能塞进原 csize 的关键)。
|
||||
"""
|
||||
q = img.convert('RGBA').quantize(colors=max(2, min(256, colors)),
|
||||
method=Image.FASTOCTREE, dither=Image.NONE)
|
||||
if q.palette.mode == 'RGBA':
|
||||
pal = list(q.getpalette('RGBA'))
|
||||
else:
|
||||
rgb = q.getpalette('RGB')
|
||||
pal = [v for i in range(256) for v in (rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2], 255)]
|
||||
pal = (pal + [0] * 1024)[:1024]
|
||||
pal_b = bytearray()
|
||||
for i in range(256): # 存储序:B, G, R, A
|
||||
r, g, b, a = pal[i * 4:i * 4 + 4]
|
||||
pal_b += bytes((b, g, r, a))
|
||||
idx = q.tobytes()
|
||||
w, h = img.size
|
||||
rows = bytearray()
|
||||
for y in range(h):
|
||||
rows += idx[y * w:(y + 1) * w]
|
||||
if stride > w:
|
||||
rows += b'\x00' * (stride - w)
|
||||
return bytes(pal_b) + bytes(rows)
|
||||
|
||||
|
||||
# 从大到小试:优先保真,装不下再减色
|
||||
RLE_COLOR_STEPS = (256, 192, 160, 128, 96, 80, 64, 48, 40, 32, 24, 16, 12, 8, 6, 4)
|
||||
|
||||
|
||||
def encode_into_slot(img, stock_blob, slot_len=None, colors=None):
|
||||
"""把 img 编成与 stock_blob **逐字节等长** 的 LVGL v9 blob(沿用原 header)。
|
||||
|
||||
真实固件里这些 UI 素材有两种槽位形态,都必须支持,否则 ROMFS 后面所有偏移全平移:
|
||||
* ARGB8888 未压缩(144x144 图标那种):像素流 + 零填充到原长度
|
||||
* I8 + RLE 压缩(walkie_talkie 里几乎全是):量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize
|
||||
(颜色数从 256 逐级往下试,取第一个能塞进原 csize 的)
|
||||
返回 (blob, how);不支持 / 压缩后仍超长时返回 (None, 原因)。
|
||||
"""
|
||||
h = img_header(stock_blob)
|
||||
if h is None:
|
||||
return None, '不是 LVGL bin'
|
||||
w, ht, cf, flags = h['w'], h['h'], h['cf'], h['flags']
|
||||
if slot_len is None:
|
||||
slot_len = len(stock_blob)
|
||||
out_img = img.convert('RGBA')
|
||||
if out_img.size != (w, ht):
|
||||
# 约定:**长宽比不一致的素材直接不替换**(保持原厂图),而不是拉伸或裁切 ——
|
||||
# 拉伸会把方块变长方形;裁切会切掉按钮两端。宁可保持原厂,也不给设备塞变形图。
|
||||
pw, ph = out_img.size
|
||||
if abs(pw / ph - w / ht) / (w / ht) > 0.005:
|
||||
return None, ('长宽比不一致(源 %dx%d = %.3f,槽位 %dx%d = %.3f)-> 按约定跳过,保持原厂'
|
||||
% (pw, ph, pw / ph, w, ht, w / ht))
|
||||
out_img = out_img.resize((w, ht), RESAMPLE)
|
||||
if cf == CF_ARGB8888 and not (flags & 0x08):
|
||||
blob = stock_blob[:12] + encode_argb8888(out_img)
|
||||
if len(blob) > slot_len:
|
||||
return None, 'ARGB 编码 %d > 槽位 %d' % (len(blob), slot_len)
|
||||
return blob + b'\x00' * (slot_len - len(blob)), 'ARGB(补 %d)' % (slot_len - len(blob))
|
||||
if cf == CF_I8 and (flags & 0x08):
|
||||
method, csize, usize = struct.unpack('<III', stock_blob[12:24])
|
||||
stride = h['stride'] or w
|
||||
best = None
|
||||
for n in (colors,) if colors else RLE_COLOR_STEPS:
|
||||
raw = raw_i8_strided(out_img, stride, n)
|
||||
comp = rle_compress(raw)
|
||||
# 末尾还要补一条「重复 1 次」指令(2 B):原厂流解出来正好是 usize 字节
|
||||
# (= 1024 + stride*h + 1),少这 1 字节设备端解码器可能直接判失败。
|
||||
if len(comp) + 2 <= csize:
|
||||
best = (n, raw, comp)
|
||||
break
|
||||
if best is None:
|
||||
n_last = RLE_COLOR_STEPS[-1]
|
||||
comp = rle_compress(raw_i8_strided(out_img, stride, n_last))
|
||||
return None, 'RLE 压缩后 %d > csize %d(已减到 %d 色)' % (len(comp), csize, n_last)
|
||||
n, raw, comp = best
|
||||
comp = comp + bytes((1, raw[-1])) # 把流做到正好 usize 字节,和原厂一致
|
||||
padded = pad_rle(comp, csize)
|
||||
if padded is None:
|
||||
return None, '补齐失败'
|
||||
blob = stock_blob[:12] + struct.pack('<III', 1, csize, usize) + padded
|
||||
if len(blob) != slot_len:
|
||||
return None, '组装后 %d != 槽位 %d' % (len(blob), slot_len)
|
||||
return blob, 'RLE %d色 %d/%d' % (n, len(comp), csize)
|
||||
return None, 'cf=0x%02x flags=%d 不支持' % (cf, flags)
|
||||
|
||||
|
||||
def targets(r):
|
||||
"""Map a flat, file-name-safe key -> inode for every launcher icon.
|
||||
|
||||
alarm/launcher.bin -> alarm
|
||||
sports/training/launcher.bin -> sports_training
|
||||
phone/contacts_launcher.bin -> phone_contacts
|
||||
"""
|
||||
out = {}
|
||||
for p, i in r.entries():
|
||||
if not p.rsplit('/', 1)[-1].endswith('launcher.bin'):
|
||||
continue
|
||||
stem = p[:-4] # drop '.bin'
|
||||
if stem.endswith('_launcher'):
|
||||
stem = stem[:-len('_launcher')]
|
||||
elif stem.endswith('/launcher'):
|
||||
stem = stem[:-len('/launcher')]
|
||||
out[stem.replace('/', '_')] = i
|
||||
return out
|
||||
|
||||
|
||||
def describe(r, i):
|
||||
h = img_header(r.read(i))
|
||||
if not h:
|
||||
return '?', '?', '?'
|
||||
return f'{h["w"]}x{h["h"]}', FMT.get(h['cf'], f'0x{h["cf"]:02x}'), h
|
||||
|
||||
|
||||
def cmd_list(image):
|
||||
r = Romfs(image)
|
||||
print(f'{os.path.basename(image)} volume={r.volume} romfs_size={r.sb_size}')
|
||||
for app, i in sorted(targets(r).items()):
|
||||
dim, fmt, _ = describe(r, i)
|
||||
print(f' {app:<26} data@0x{i.data_off:08x} len={i.size:<7} {dim} {fmt}')
|
||||
|
||||
|
||||
def cmd_make(image, pngdir, outdir, pad=False, write_patched=False):
|
||||
r = Romfs(image)
|
||||
tg = targets(r)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
payload_dir = os.path.join(outdir, 'payload')
|
||||
os.makedirs(payload_dir, exist_ok=True)
|
||||
patch = bytearray()
|
||||
rollback = bytearray()
|
||||
records, skipped = [], []
|
||||
for fn in sorted(os.listdir(pngdir)):
|
||||
if not fn.lower().endswith('.png'):
|
||||
continue
|
||||
app = fn[:-4]
|
||||
if app not in tg:
|
||||
skipped.append((app, 'no such app'))
|
||||
continue
|
||||
ino = tg[app]
|
||||
orig = r.read(ino)
|
||||
_, fmt, h = describe(r, ino)
|
||||
if h is None:
|
||||
skipped.append((app, f'not LVGL image (len={ino.size})'))
|
||||
continue
|
||||
src = Image.open(os.path.join(pngdir, fn)).convert('RGBA')
|
||||
src_dim = f'{src.size[0]}x{src.size[1]}'
|
||||
resized = False
|
||||
if src.size != (h['w'], h['h']):
|
||||
if pad:
|
||||
tw, th = src.size
|
||||
blob = encode(src, h['cf'], tw, th, h['flags'], pad_to=len(orig))
|
||||
else:
|
||||
src = src.resize((h['w'], h['h']), RESAMPLE)
|
||||
tw, th = src.size
|
||||
resized = True
|
||||
blob = encode(src, h['cf'], tw, th, h['flags'])
|
||||
else:
|
||||
tw, th = src.size
|
||||
blob = encode(src, h['cf'], tw, th, h['flags'])
|
||||
if len(blob) != len(orig):
|
||||
skipped.append((app, f'length {len(blob)} != {len(orig)}'))
|
||||
continue
|
||||
patch += struct.pack('<II', ino.data_off, len(blob)) + blob
|
||||
rollback += struct.pack('<II', ino.data_off, len(orig)) + orig
|
||||
open(os.path.join(payload_dir, app.replace('/', '_') + '.bin'), 'wb').write(blob)
|
||||
records.append(dict(app=app, offset=ino.data_off, length=len(blob),
|
||||
format=fmt, stock_dim=f'{h["w"]}x{h["h"]}',
|
||||
source=fn, source_dim=src_dim,
|
||||
resized=resized, padded=bool(pad and src.size != (h['w'], h['h'])),
|
||||
sha256=hashlib.sha256(blob).hexdigest()))
|
||||
|
||||
open(os.path.join(outdir, 'icons.patch'), 'wb').write(patch)
|
||||
open(os.path.join(outdir, 'icons.rollback'), 'wb').write(rollback)
|
||||
manifest = dict(image=os.path.basename(image), volume=r.volume,
|
||||
image_sha256=hashlib.sha256(r.data).hexdigest(),
|
||||
replace_count=len(records), records=records,
|
||||
skipped=[{'app': a, 'reason': b} for a, b in skipped])
|
||||
json.dump(manifest, open(os.path.join(outdir, 'manifest.json'), 'w'),
|
||||
indent=1, ensure_ascii=False)
|
||||
|
||||
print(f'{"app":<26}{"offset":>12}{"bytes":>8} {"fmt":<10}{"stock":>9}{"source":>9} note')
|
||||
for m in records:
|
||||
note = 'resized' if m['resized'] else ('padded' if m['padded'] else '')
|
||||
print(f'{m["app"]:<26}0x{m["offset"]:08x}{m["length"]:>8} {m["format"]:<10}'
|
||||
f'{m["stock_dim"]:>9}{m["source_dim"]:>9} {note}')
|
||||
for a, why in skipped:
|
||||
print(f' SKIP {a}: {why}')
|
||||
print(f'\n{len(records)} replacement(s)')
|
||||
print(f' payload -> {payload_dir}')
|
||||
print(f' patch -> {os.path.join(outdir, "icons.patch")} ({len(patch)} bytes)')
|
||||
print(f' rollback -> {os.path.join(outdir, "icons.rollback")} ({len(rollback)} bytes)')
|
||||
print(f' manifest -> {os.path.join(outdir, "manifest.json")}')
|
||||
if write_patched:
|
||||
out = os.path.join(outdir, 'vela_app_patched.bin')
|
||||
cmd_apply(image, os.path.join(outdir, 'icons.patch'), out)
|
||||
|
||||
|
||||
def cmd_apply(image, patch, out):
|
||||
data = bytearray(open(image, 'rb').read())
|
||||
blob = open(patch, 'rb').read()
|
||||
off = n = 0
|
||||
while off < len(blob):
|
||||
addr, ln = struct.unpack('<II', blob[off:off+8])
|
||||
chunk = blob[off+8:off+8+ln]
|
||||
assert len(chunk) == ln and addr + ln <= len(data), 'corrupt patch'
|
||||
data[addr:addr+ln] = chunk
|
||||
off += 8 + ln
|
||||
n += 1
|
||||
open(out, 'wb').write(data)
|
||||
print(f'applied {n} record(s) -> {out}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
a = sys.argv[1:]
|
||||
if not a:
|
||||
print(__doc__)
|
||||
elif a[0] == 'list':
|
||||
cmd_list(a[1])
|
||||
elif a[0] == 'make':
|
||||
cmd_make(a[1], a[2], a[3], '--pad' in a, '--patched' in a)
|
||||
elif a[0] == 'apply':
|
||||
cmd_apply(a[1], a[2], a[3])
|
||||
else:
|
||||
print(__doc__)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Crude but effective Lua block-balance checker: strips comments and strings,
|
||||
then counts block openers vs `end`."""
|
||||
import sys, re
|
||||
|
||||
def strip(src):
|
||||
out = []
|
||||
i, n = 0, len(src)
|
||||
while i < n:
|
||||
c = src[i]
|
||||
# long bracket comment / string --[[ ]] , --[==[ ]==]
|
||||
m = re.match(r'--\[(=*)\[', src[i:])
|
||||
if m:
|
||||
close = ']' + m.group(1) + ']'
|
||||
j = src.find(close, i)
|
||||
i = n if j < 0 else j + len(close)
|
||||
out.append(' ')
|
||||
continue
|
||||
if src.startswith('--', i):
|
||||
j = src.find('\n', i)
|
||||
i = n if j < 0 else j
|
||||
out.append(' ')
|
||||
continue
|
||||
m = re.match(r'\[(=*)\[', src[i:])
|
||||
if m:
|
||||
close = ']' + m.group(1) + ']'
|
||||
j = src.find(close, i)
|
||||
i = n if j < 0 else j + len(close)
|
||||
out.append('""')
|
||||
continue
|
||||
if c in '"\'':
|
||||
j = i + 1
|
||||
while j < n:
|
||||
if src[j] == '\\':
|
||||
j += 2
|
||||
continue
|
||||
if src[j] == c:
|
||||
break
|
||||
j += 1
|
||||
i = j + 1
|
||||
out.append('""')
|
||||
continue
|
||||
out.append(c)
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def check(path):
|
||||
src = open(path, encoding='utf-8').read()
|
||||
code = strip(src)
|
||||
def cnt(k):
|
||||
return len(re.findall(r'(?<![\w.])' + k + r'(?![\w])', code))
|
||||
# every while/for consumes exactly one `do`, wherever it sits (may wrap lines)
|
||||
do_total = cnt('do')
|
||||
do_standalone = do_total - cnt('while') - cnt('for')
|
||||
openers = cnt('function') + cnt('if') + cnt('while') + cnt('for') + do_standalone
|
||||
enders = cnt('end')
|
||||
repeat_n, until_n = cnt('repeat'), cnt('until')
|
||||
print(f'{path}')
|
||||
print(f' function={cnt("function")} if={cnt("if")} while={cnt("while")} '
|
||||
f'for={cnt("for")} do(standalone)={do_standalone} repeat={repeat_n}')
|
||||
print(f' openers={openers} end={enders} until={until_n}')
|
||||
ok = (openers == enders) and (repeat_n == until_n)
|
||||
print(f' BALANCED: {ok}')
|
||||
if not ok:
|
||||
print(' ^^^ 括号/块不匹配,需要检查')
|
||||
return ok
|
||||
|
||||
|
||||
allok = True
|
||||
for p in sys.argv[1:]:
|
||||
allok &= check(p)
|
||||
print('\nALL OK' if allok else '\nPROBLEM')
|
||||
@@ -0,0 +1,168 @@
|
||||
"""LVGL v9 RLE codec (LV_IMAGE_COMPRESS_RLE).
|
||||
|
||||
Control byte grammar (upstream `lv_rle_decompress`):
|
||||
high bit CLEAR (0x00..0x7F) : repeat the next single `blk`-byte block `ctrl` times
|
||||
high bit SET (0x80|count) : copy `count` blocks verbatim (literal run)
|
||||
count is 1..=127
|
||||
For palette formats (I8) the stream decodes to 256*4 palette bytes followed by
|
||||
w*h palette indices.
|
||||
"""
|
||||
import struct
|
||||
from PIL import Image
|
||||
|
||||
MAGIC = 0x19
|
||||
FLAG_COMPRESSED = 0x08
|
||||
METHOD_NONE, METHOD_RLE, METHOD_LZ4 = 0, 1, 2
|
||||
|
||||
|
||||
def rle_decompress(data, blk, out_len=None):
|
||||
out = bytearray()
|
||||
i, n = 0, len(data)
|
||||
while i < n:
|
||||
ctrl = data[i]; i += 1
|
||||
if ctrl & 0x80:
|
||||
cnt = ctrl & 0x7F
|
||||
nb = cnt * blk
|
||||
if i + nb > n:
|
||||
raise ValueError('truncated literal run')
|
||||
out += data[i:i + nb]
|
||||
i += nb
|
||||
else:
|
||||
cnt = ctrl
|
||||
if i + blk > n:
|
||||
raise ValueError('truncated repeat unit')
|
||||
unit = data[i:i + blk]
|
||||
i += blk
|
||||
out += unit * cnt
|
||||
if out_len is not None and len(out) >= out_len:
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rle_compress(raw, max_run=127):
|
||||
"""LVGL RLE 编码器(rle_decompress 的逆向,blk=1)。
|
||||
|
||||
贪心:先找同字节游程(>=2 用「重复」指令),否则攒「原样」指令。
|
||||
输出长度总是 <= 原图像素流,因为最坏情况每条 literal 只多 1 个控制字节。
|
||||
"""
|
||||
out = bytearray()
|
||||
i, n = 0, len(raw)
|
||||
while i < n:
|
||||
v = raw[i]
|
||||
j = i + 1
|
||||
while j < n and raw[j] == v and j - i < max_run:
|
||||
j += 1
|
||||
run = j - i
|
||||
if run >= 2:
|
||||
out.append(run); out.append(v); i = j
|
||||
continue
|
||||
lit = bytearray()
|
||||
k = i
|
||||
while k < n and len(lit) < max_run:
|
||||
if k + 1 < n and raw[k] == raw[k + 1]:
|
||||
break
|
||||
lit.append(raw[k]); k += 1
|
||||
if not lit: # 理论上到不了这里
|
||||
lit.append(v); k = i + 1
|
||||
out.append(0x80 | len(lit)); out += lit
|
||||
i = k
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def pad_rle(comp, want):
|
||||
"""把 RLE 流补到 want 字节:用 ctrl=0(重复 0 次 = 产出 0 像素)的 2 字节空指令。
|
||||
|
||||
ctrl=0 是合法指令(`len = 0`,两个越界判断都不触发),所以设备端解码器读完 csize
|
||||
字节既不会越界、也不会返回 INVALID —— 这是「等长覆盖」能成立的关键。
|
||||
差值为奇数时先把一条 literal 拆成两条(payload 不变、多 1 个控制字节)。
|
||||
"""
|
||||
d = want - len(comp)
|
||||
if d < 0:
|
||||
return None
|
||||
if d % 2:
|
||||
b = bytearray(comp)
|
||||
for idx in range(len(b) - 1, -1, -1):
|
||||
if b[idx] & 0x80 and (b[idx] & 0x7F) >= 2:
|
||||
cnt = b[idx] & 0x7F
|
||||
lit = bytes(b[idx + 1: idx + 1 + cnt])
|
||||
half = cnt // 2
|
||||
b[idx: idx + 1 + cnt] = (bytes([0x80 | half]) + lit[:half] +
|
||||
bytes([0x80 | (cnt - half)]) + lit[half:])
|
||||
break
|
||||
else:
|
||||
return None
|
||||
comp = bytes(b)
|
||||
d -= 1
|
||||
return comp + b'\x00\x00' * (d // 2)
|
||||
|
||||
|
||||
def parse_header(blob):
|
||||
if len(blob) < 12 or blob[0] != MAGIC:
|
||||
return None
|
||||
magic, cf, flags, w, h, stride, rsv = struct.unpack('<BBHHHHH', blob[:12])
|
||||
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=rsv)
|
||||
|
||||
|
||||
def decode_lvgl_bin(blob):
|
||||
"""Return (header, RGBA PIL image) for uncompressed / RLE LVGL v9 .bin.
|
||||
|
||||
NOTE stride: LVGL pads each row to the header's `stride`, which is NOT always
|
||||
equal to w (e.g. the S5 41mm control-center icons are 116 wide with stride 128).
|
||||
Reading them as w*h contiguous pixels yields rainbow garbage, so the row pitch
|
||||
below is taken from the header.
|
||||
"""
|
||||
h = parse_header(blob)
|
||||
if h is None:
|
||||
return None, None
|
||||
cf, w, ht = h['cf'], h['w'], h['h']
|
||||
body = blob[12:]
|
||||
if h['flags'] & FLAG_COMPRESSED:
|
||||
method, csize, usize = struct.unpack('<III', body[:12])
|
||||
if method != METHOD_RLE:
|
||||
raise ValueError(f'unsupported compression method {method}')
|
||||
blk = 4 if cf == 0x10 else 1 # ARGB8888 是 4 字节一块,I8 是 1 字节
|
||||
raw = rle_decompress(body[12:12 + csize], blk, usize)
|
||||
else:
|
||||
raw = body
|
||||
img = Image.new('RGBA', (w, ht))
|
||||
px = img.load()
|
||||
if cf == 0x10: # ARGB8888 (BGRA)
|
||||
pitch = max(h['stride'] or 0, w * 4)
|
||||
if len(raw) < pitch * ht:
|
||||
return h, None
|
||||
for y in range(ht):
|
||||
row = y * pitch
|
||||
for x in range(w):
|
||||
b, g, r, a = raw[row + x * 4:row + x * 4 + 4]
|
||||
px[x, y] = (r, g, b, a)
|
||||
elif cf == 0x0a: # I8: palette then indices
|
||||
pitch = max(h['stride'] or 0, w)
|
||||
if len(raw) < 1024 + pitch * ht:
|
||||
return h, None
|
||||
pal = raw[:1024]
|
||||
palette = [(pal[i*4+2], pal[i*4+1], pal[i*4+0], pal[i*4+3]) for i in range(256)]
|
||||
for y in range(ht):
|
||||
row = 1024 + y * pitch
|
||||
for x in range(w):
|
||||
px[x, y] = palette[raw[row + x]]
|
||||
else:
|
||||
return h, None
|
||||
return h, img
|
||||
|
||||
|
||||
def pad_rle_safe(comp, want):
|
||||
"""把 RLE 流补齐到 want 字节 —— 只用两类**产出 0 像素**的合法指令:
|
||||
ctrl=0x00 + unit : 重复 0 次(2 字节)
|
||||
0x80 : 原样 0 个(1 字节,用来修奇偶)
|
||||
两者都能通过上游 lv_rle_decompress 的边界判断(len 为 0,不越界)。
|
||||
注意:不要像 pad_rle 那样去“拆一条 literal”修奇偶 —— 那是按裸字节扫描的,
|
||||
会命中负载里的普通字节、把流改坏(本工具就是踩了这个坑才发现)。"""
|
||||
d = want - len(comp)
|
||||
if d < 0:
|
||||
return None
|
||||
out = bytearray(comp)
|
||||
out += b'\x00\x00' * (d // 2)
|
||||
if d % 2:
|
||||
out.append(0x80)
|
||||
assert len(out) == want
|
||||
return bytes(out)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""项目里所有脚本的路径入口 —— 换机器 / 换目录只看这一个文件。
|
||||
|
||||
默认布局(都相对项目根):
|
||||
inputs/target/ 目标固件的分区镜像(要自己放,见 README)
|
||||
inputs/source/ 素材来源的分区镜像(要自己放)
|
||||
inputs/fonts/ 可选:MiSans-*.ttf(渲染预览用;不给就从 vela_font.bin 里取)
|
||||
vendor/ 第三方 LuaDevTemplate-main(里面有 Compiler.exe,要自己放)
|
||||
.work/ 构建工作区(产物,可删)
|
||||
dist/ 交付的 .face
|
||||
docs/ 说明与预览图
|
||||
|
||||
环境变量可覆盖:ICON_EDIT_INPUTS / ICON_EDIT_VENDOR / ICON_EDIT_WORK /
|
||||
ICON_EDIT_DIST / ICON_EDIT_DOCS / ICON_EDIT_FONTS
|
||||
"""
|
||||
import os
|
||||
|
||||
PROJ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _p(env, default):
|
||||
return os.environ.get(env) or os.path.join(PROJ, default)
|
||||
|
||||
|
||||
INPUTS = _p('ICON_EDIT_INPUTS', 'inputs')
|
||||
VENDOR = _p('ICON_EDIT_VENDOR', 'vendor')
|
||||
WORK = _p('ICON_EDIT_WORK', '.work')
|
||||
DIST = _p('ICON_EDIT_DIST', 'dist')
|
||||
DOCS = _p('ICON_EDIT_DOCS', 'docs')
|
||||
FONTS = os.environ.get('ICON_EDIT_FONTS') or os.path.join(INPUTS, 'fonts')
|
||||
TEMPLATE = os.path.join(VENDOR, 'LuaDevTemplate-main')
|
||||
INPUT_TARGET = os.path.join(INPUTS, 'target')
|
||||
INPUT_SOURCE = os.path.join(INPUTS, 'source')
|
||||
ASSETS = os.path.join(PROJ, 'assets')
|
||||
|
||||
for _d in (WORK, DIST, DOCS, INPUT_TARGET, INPUT_SOURCE):
|
||||
os.makedirs(_d, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------ 本项目的事实
|
||||
# 目标:Xiaomi Watch S5 eSIM 46mm(p62lte, 480x480, Lua 5.4 宿主)
|
||||
# 素材来源:Xiaomi Watch S5 41mm(q63, v4.101.020)
|
||||
TARGET_IMAGE = os.path.join(INPUT_TARGET, 'vela_health.bin') # 要改的分区(卷名就是 health)
|
||||
SOURCE_IMAGE = os.path.join(INPUT_SOURCE, 'vela_app.bin') # q63 的 100 帧在这里
|
||||
TARGET_VOLUME = 'health'
|
||||
DIRP = 'pressure/measure/'
|
||||
FACE_W = FACE_H = 464 # 槽位是 464x464 I8 + RLE
|
||||
SRC_W = SRC_H = 480 # q63 的帧是 480x480
|
||||
SCREEN = 480 # 表盘界面 480x480
|
||||
WF_ID = '462150102' # 两份盘共用(462 = S5 家族)
|
||||
DEVICE_TYPE = '462'
|
||||
PREVIEW = 326 # Compiler.exe 对 462 设备强制 326x326
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Minimal reader for Xiaomi Vela ROMFS partition images (Linux 'rom1fs' variant).
|
||||
|
||||
Layout (big-endian):
|
||||
super : magic "-rom1fs-", u32 size, u32 checksum, name (NUL-term, padded to 16)
|
||||
inode : u32 next (low 4 bits = ROMFH_ type flags), u32 spec, u32 size, u32 checksum,
|
||||
name (NUL-term, padded to 16), then file data immediately after.
|
||||
type : 0=hardlink (spec = target inode offset), 1=dir (spec = offset of first child),
|
||||
2=regular file, 8=exec bit OR-ed in.
|
||||
For a directory, its children are the chain starting at `spec`, linked by
|
||||
`next & ~0xF`, and they stay inside the directory's own block.
|
||||
"""
|
||||
import struct, os
|
||||
|
||||
ROUND16 = lambda n: (n + 15) & ~15
|
||||
|
||||
class Inode:
|
||||
__slots__ = ('off', 'next', 'spec', 'size', 'cksum', 'name', 'namelen', 'data_off', 'kind')
|
||||
|
||||
@property
|
||||
def type_flags(self):
|
||||
return self.next & 0xF
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self.next & 0x7
|
||||
|
||||
@property
|
||||
def exec_bit(self):
|
||||
return bool(self.next & 0x8)
|
||||
|
||||
@property
|
||||
def next_off(self):
|
||||
return self.next & ~0xF
|
||||
|
||||
KIND = {0: 'HRD', 1: 'DIR', 2: 'REG', 3: 'SYM', 4: 'BLK', 5: 'CHR', 6: 'SCK', 7: 'FIF'}
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<Inode @0x{self.off:x} {self.KIND[self.type]}{"X" if self.exec_bit else " "} '
|
||||
f'{self.name!r} size={self.size}>')
|
||||
|
||||
|
||||
class Romfs:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.data = open(path, 'rb').read()
|
||||
d = self.data
|
||||
if d[0:8] != b'-rom1fs-':
|
||||
raise ValueError('not a romfs image')
|
||||
self.sb_size, self.sb_cksum = struct.unpack('>II', d[8:16])
|
||||
end = d.index(b'\x00', 16)
|
||||
self.volume = d[16:end].decode('latin1')
|
||||
self.root_off = 16 + ROUND16(end - 16 + 1)
|
||||
|
||||
def inode(self, off):
|
||||
i = Inode()
|
||||
i.off = off
|
||||
i.next, i.spec, i.size, i.cksum = struct.unpack('>IIII', self.data[off:off+16])
|
||||
p = off + 16
|
||||
end = self.data.find(b'\x00', p, p + 256)
|
||||
if end < 0:
|
||||
raise ValueError(f'bad name at 0x{off:x}')
|
||||
i.name = self.data[p:end].decode('latin1')
|
||||
i.namelen = end - p
|
||||
i.data_off = off + 16 + ROUND16(i.namelen + 1)
|
||||
return i
|
||||
|
||||
def walk(self, first, limit, prefix=''):
|
||||
"""Yield (path, Inode) for every entry reachable from `first` below `limit`.
|
||||
|
||||
`limit` bounds the sibling chain. The LAST entry of a chain has next==0,
|
||||
but its own children still live after it: they inherit the parent's limit
|
||||
instead of being bounded by that zero.
|
||||
"""
|
||||
off = first
|
||||
seen = set()
|
||||
while off and (limit == 0 or off < limit) and off not in seen:
|
||||
seen.add(off)
|
||||
i = self.inode(off)
|
||||
path = prefix + i.name
|
||||
yield path, i
|
||||
if i.type == 1 and i.spec: # directory -> first child at spec
|
||||
yield from self.walk(i.spec, i.next_off or limit, path + '/')
|
||||
off = i.next_off
|
||||
|
||||
def entries(self):
|
||||
"""All entries of the image, as (path, Inode)."""
|
||||
return self.walk(self.root_off + 0x20, len(self.data))
|
||||
|
||||
def read(self, ino):
|
||||
return self.data[ino.data_off:ino.data_off + ino.size]
|
||||
|
||||
|
||||
def img_header(blob):
|
||||
if len(blob) < 12 or blob[0] != 0x19:
|
||||
return None
|
||||
magic, cf, flags, w, h, stride, res = struct.unpack('<BBHHHHH', blob[:12])
|
||||
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=res)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
r = Romfs(sys.argv[1])
|
||||
print(f'{os.path.basename(sys.argv[1])}: volume={r.volume!r} size={r.sb_size} '
|
||||
f'file={len(r.data)} root=0x{r.root_off:x}')
|
||||
n = 0
|
||||
for path, i in r.entries():
|
||||
n += 1
|
||||
if len(sys.argv) > 2 and sys.argv[2] not in path:
|
||||
continue
|
||||
kind = ('DIR ' if i.type == 1 else 'LNK ' if i.type == 0 else 'FILE')
|
||||
print(f'{kind} {i.size:>9} {path}')
|
||||
print(f'-- total entries: {n}')
|
||||
@@ -0,0 +1,562 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 S5 41mm(q63) 的 `pressure/measure/Measuring1..100.bin`(480x480)逐帧等长写进
|
||||
S5 eSIM(p62lte) `vela_health.bin` 的 `pressure/measure/Measuring1..119.bin`(464x464 I8+RLE)。
|
||||
|
||||
硬约束(为什么必须"逐字节等长")
|
||||
--------------------------------
|
||||
ROMFS 里 `next` / `spec` 全是**绝对偏移**,文件数据又紧随 inode 之后;任何一条负载
|
||||
长度变化 1 字节,后面所有文件的偏移就整体平移 → 分区报废。所以:
|
||||
|
||||
12 B 头 + 12 B 压缩头 + csize 必须 == 原槽位长度
|
||||
csize 原样沿用(解码器按 csize 读),usize 原样沿用(= 1024 + 464*464 = 216321)
|
||||
|
||||
四步流水线
|
||||
----------
|
||||
1) 解码 q63 帧 → 缩放到 464x464(**缩小**,LANCZOS)
|
||||
2) 暗部归零:q63 帧的"黑"其实是一条均匀的 #1a1a1a(26,26,26) 底噪 + 抖动噪声,
|
||||
实测占 68% 的像素、熵极高(256 色几乎全糊在暗部,不处理时 RLE 压不动:172 KB)。
|
||||
阈值以下的像素一律置 0,背景塌成整行单游程,体积掉到 1/4(~45 KB)。
|
||||
3) 量化:按一条**单调质量阶梯**(缩放比例 + 颜色数一起动)挑能塞进 csize 的最高档。
|
||||
4) LVGL RLE 压缩 → 先补「重复 N 次」把解码长度做到**正好 usize**,
|
||||
再用 `pad_rle_safe` 的合法空指令(00 00 / 80)补齐到 csize。
|
||||
|
||||
为什么用"质量单参数"而不是逐槽各自最优
|
||||
--------------------------------------
|
||||
槽位预算从 4.5 KB 到 196 KB 差 40 倍。逐帧各自最优会让相邻帧出现
|
||||
「2 色 x0.32」跳到「4 色 x0.80」这种忽大忽小(S4e 那版踩过)。这里把
|
||||
(颜色数, 缩放) 绑成一个连续量,先算每槽能撑住的最大档,再从峰值向两侧取
|
||||
「运行最小值」—— 曲线单峰且平滑,画面尺寸/色阶随预算连续变化。
|
||||
|
||||
产出
|
||||
----
|
||||
.work/anim/vela_health_anim.bin 改后镜像(长度 == 原镜像)
|
||||
.work/anim/manifest.json 逐条清单(偏移/长度/映射/降级/保留)
|
||||
"""
|
||||
import os, sys, json, struct, shutil, hashlib, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from paths import (PROJ, INPUTS, WORK, TARGET_IMAGE, SOURCE_IMAGE, TARGET_VOLUME,
|
||||
DIRP, FACE_W, FACE_H)
|
||||
from romfs import Romfs
|
||||
import lvgl_bin as L
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
OUTDIR = os.path.join(WORK, 'anim')
|
||||
PATCHED = os.path.join(OUTDIR, 'vela_health_anim.bin')
|
||||
# 设备端解码器要求流解出的字节数**正好** == 头里的 usize。原厂 119 条全是 216321,
|
||||
# 比"调色板 + 像素"多 1 字节(解码器只用到前 1024 + 464*464 字节,多出来的 1 字节
|
||||
# 落在行外、被 `y < h` 挡掉,所以既安全又是原厂行为)。实测值由 build() 从镜像里读出来,
|
||||
# 不在这儿写常量 —— 写错过一次(写成 216320)就被下面的断言当场抓住。
|
||||
USIZE = 1024 + FACE_W * FACE_H # 216320:调色板 + 像素;真实值以镜像为准
|
||||
TAIL_EXTRA = 1 # 期望的原厂尾部额外字节数
|
||||
USIZE_EXTRA = TAIL_EXTRA # 实际值由 build() 从镜像读出来后覆盖
|
||||
|
||||
# ------------------------------------------------------------------ 暗部归零
|
||||
# q63 帧的底噪是 #1a1a1a(26),球体最暗的实体像素也 >100。取 40 吃掉整片底噪与抖动,
|
||||
# 又不动球体本体;56 作为第二档兜底(更狠,留给塞不下的槽位)。
|
||||
DARK_LEVELS = (40, 56)
|
||||
|
||||
# ------------------------------------------------------------------ 固定调色板(低预算兜底)
|
||||
# 到 8 色以下时"重新聚类"会把仅剩的颜色浪费在底噪残渣上、球体反而变花;
|
||||
# 改用这几套手工调色板(索引 0 一定是黑),画面干净、也更好压。
|
||||
FIXED_PALETTES = {
|
||||
2: [(0, 0, 0), (92, 132, 255)],
|
||||
3: [(0, 0, 0), (40, 60, 190), (120, 190, 255)],
|
||||
4: [(0, 0, 0), (28, 40, 150), (70, 120, 245), (140, 220, 210)],
|
||||
6: [(0, 0, 0), (18, 26, 110), (44, 66, 200), (80, 130, 250),
|
||||
(140, 200, 245), (150, 220, 160)],
|
||||
8: [(0, 0, 0), (12, 18, 80), (26, 38, 140), (46, 70, 195), (72, 110, 240),
|
||||
(110, 165, 252), (165, 215, 250), (150, 222, 168)],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 质量阶梯
|
||||
# 从「最好」到「最省」单调排列:(缩放, 颜色数, 固定调色板色数|None)
|
||||
LADDER = [
|
||||
(1.00, 256, None), (1.00, 192, None), (1.00, 128, None), (1.00, 96, None),
|
||||
(0.95, 96, None), (0.95, 64, None), (0.90, 64, None),
|
||||
(0.85, 64, None), (0.85, 48, None), (0.80, 48, None),
|
||||
(0.75, 48, None), (0.75, 32, None), (0.70, 32, None),
|
||||
(0.65, 32, None), (0.65, 24, None), (0.60, 24, None),
|
||||
(0.55, 24, None), (0.55, 16, None), (0.50, 16, None),
|
||||
(0.45, 16, None), (0.45, 12, None), (0.40, 12, None),
|
||||
(0.35, 12, None), (0.35, 8, 8), (0.30, 8, 8),
|
||||
(0.26, 6, 6), (0.22, 6, 6), (0.19, 4, 4), (0.16, 4, 4),
|
||||
(0.13, 3, 3), (0.11, 3, 3), (0.09, 2, 2), (0.07, 2, 2), (0.05, 2, 2),
|
||||
]
|
||||
CI_KEEP = len(LADDER) # 哨兵:连最省档都塞不下
|
||||
# 平滑之后允许比目标档**大**多少缩放:0 表示只许更小。留一点余量是为了让球体尽量顶到
|
||||
# 预算上限,同时不给相邻帧制造肉眼可见的尺寸跳变(每档缩放差约 0.05)。
|
||||
SCALE_SLACK = 0.02
|
||||
|
||||
|
||||
def num_of(path):
|
||||
return int(path.rsplit('Measuring', 1)[-1].replace('.bin', ''))
|
||||
|
||||
|
||||
def collect(rom):
|
||||
d = {}
|
||||
for p, i in rom.entries():
|
||||
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0:
|
||||
continue
|
||||
d[num_of(p)] = (p, i, rom.read(i))
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 编码原语
|
||||
def palette_bytes(cols):
|
||||
"""1024 B BGRA;索引 0 一定是黑,没用的条目留 0 —— 调色板本身也好压。"""
|
||||
out = bytearray(1024)
|
||||
for k, (r, g, b) in enumerate(cols[:256]):
|
||||
out[k * 4:k * 4 + 4] = bytes((b, g, r, 255))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def kill_dark(img, dark):
|
||||
a = np.asarray(img.convert('RGB')).copy()
|
||||
a[a.max(axis=2) < dark] = 0
|
||||
return Image.fromarray(a, 'RGB')
|
||||
|
||||
|
||||
def scale_image(img, sc):
|
||||
if sc >= 0.999:
|
||||
return img
|
||||
s = max(4, int(round(FACE_W * sc)))
|
||||
small = img.convert('RGBA').resize((s, s), Image.LANCZOS)
|
||||
cv = Image.new('RGBA', (FACE_W, FACE_H), (0, 0, 0, 255))
|
||||
cv.paste(small, ((FACE_W - s) // 2, (FACE_H - s) // 2))
|
||||
return cv
|
||||
|
||||
|
||||
def quantize_raw(rgb, ncol, fixed):
|
||||
"""-> 1024 B 调色板 + w*h 个索引(LVGL I8 的裸像素流)。
|
||||
|
||||
自己实现量化,不再借 PIL 的调色板 API —— 这里踩过两个坑,留作警钟:
|
||||
① 为了"保证索引 0 是黑",先把图贴到 (W+8)x(H+8) 黑画布上再 quantize,
|
||||
然后 `q.tobytes()[:W*H]` 取像素。但 q 是 472 宽的,按 464 宽读就"每行错 8 列",
|
||||
整幅图被剪成斜条纹 —— RLE 流完全正确、解出来也和"量化结果"逐点一致,
|
||||
只有拿"量化结果的形状"去比**源图**才抓得住(见 silhouette())。
|
||||
② 改成"循环移位调色板 + convert()/paste()"更糟:`quantize(palette=...)` 会把图重新
|
||||
合并成它自己算出来的调色板,`paste()` 又只搬调色板,于是 64 色时整幅图变纯黑。
|
||||
与其猜 PIL 的语义,不如把量化写成几行明确的 numpy。
|
||||
|
||||
现在:黑单独占索引 0(背景因此塌成单游程),其余 ncol-1 个颜色在**非黑像素**上做
|
||||
加权 k-means(按出现次数加权,梯度才不会被子像素级的近似黑拖偏)。固定迭代次数 => 确定。
|
||||
"""
|
||||
src = np.asarray(rgb if rgb.mode == 'RGB' else rgb.convert('RGB'))
|
||||
return quantize_arr(src, ncol, fixed)
|
||||
|
||||
|
||||
def quantize_arr(src, ncol, fixed, cache=None, ckey=None):
|
||||
"""量化主体。src 是 (H,W,3) uint8;ckey 用来复用同一帧的颜色表。
|
||||
|
||||
性能关键(第一版在这里把机器跑爆过内存 1.7 GB / CPU 卡死):
|
||||
* 源图有 1.5~2 万个不同颜色、21.5 万个像素。**质心只在"不同颜色"上算**,
|
||||
绝不在 21.5 万像素上做 (N, K, 3) 的距离广播
|
||||
(那一步就是 N=215296 x K=255 的模式,一个临时数组 1.6 GB)。
|
||||
* 像素 -> 索引 走一张**粗查色立方** LUT:先把 RGB 量化到 step 网格
|
||||
(step=4 时 64³ = 262144 个格子),只对这 26 万个格子算一次最近邻,
|
||||
再让 21.5 万像素直接查表。代价是"近似最近邻"(最大偏 step/2 = 2 个色阶),
|
||||
对 8 位调色板完全够用。
|
||||
"""
|
||||
H, W = src.shape[:2]
|
||||
if cache is None:
|
||||
cache = {}
|
||||
ent = cache.get(ckey)
|
||||
if ent is None:
|
||||
flat = src.reshape(-1, 3)
|
||||
bg = flat.max(axis=1) == 0
|
||||
upix, inv = np.unique(flat, axis=0, return_inverse=True)
|
||||
upix = np.ascontiguousarray(upix, dtype=np.int32)
|
||||
inv = inv.reshape(-1).astype(np.int32)
|
||||
fg = np.nonzero(upix.max(axis=1) > 0)[0]
|
||||
ent = (upix, inv, fg, bg, flat.shape[0])
|
||||
cache[ckey] = ent
|
||||
upix, inv, fg, bg, npix = ent
|
||||
if len(fg) == 0:
|
||||
return palette_bytes([(0, 0, 0)]) + bytes(npix)
|
||||
if fixed is not None:
|
||||
cols = FIXED_PALETTES[fixed]
|
||||
out = _lut_index(upix, cols)[inv]
|
||||
out[bg] = 0
|
||||
# 背景必须落在索引 0(黑),这样 RLE 才是一条单游程
|
||||
if cols[0] != (0, 0, 0):
|
||||
raise ValueError('固定调色板的索引 0 必须是黑')
|
||||
return palette_bytes(cols) + out.tobytes()
|
||||
k = max(1, min(255, int(ncol) - 1))
|
||||
ufg = upix[fg]
|
||||
uq, cnt = np.unique(ufg, axis=0, return_counts=True)
|
||||
if len(uq) <= k:
|
||||
centers = uq.astype(np.float64)
|
||||
else:
|
||||
centers = uq[cnt.argsort()[::-1][:k]].astype(np.float64) # 出现最多的 k 色做种子
|
||||
w = cnt.astype(np.float64)
|
||||
K = len(centers)
|
||||
for _ in range(8):
|
||||
lab = _nearest_argmin(uq, centers)
|
||||
new = centers.copy()
|
||||
for j in range(K):
|
||||
sel = lab == j
|
||||
if sel.any():
|
||||
ww = w[sel][:, None]
|
||||
new[j] = (uq[sel] * ww).sum(axis=0) / ww.sum()
|
||||
done = np.allclose(new, centers, atol=0.25)
|
||||
centers = new
|
||||
if done:
|
||||
break
|
||||
cols = [(0, 0, 0)] + [tuple(int(round(v)) for v in c) for c in centers]
|
||||
out = _lut_index(upix, cols)[inv]
|
||||
out[bg] = 0
|
||||
return palette_bytes(cols) + out.tobytes()
|
||||
|
||||
|
||||
def _nearest_argmin(px, cent):
|
||||
"""返回每个 px 最近的 cent 下标;只用 float32(int32 的立方广播会反复申请上百 MB)。"""
|
||||
a = np.asarray(px, dtype=np.float32)
|
||||
c = np.asarray(cent, dtype=np.float32)
|
||||
out = np.empty(len(a), dtype=np.int32)
|
||||
chunk = 8192
|
||||
for s in range(0, len(a), chunk):
|
||||
d = ((a[s:s + chunk, None, :] - c[None, :, :]) ** 2).sum(axis=2)
|
||||
out[s:s + chunk] = d.argmin(axis=1)
|
||||
return out
|
||||
|
||||
|
||||
_GRID_CACHE = {}
|
||||
|
||||
|
||||
def _cube_grid(step):
|
||||
"""粗查色立方的网格代表色(只算一次,全局缓存)。
|
||||
|
||||
展平顺序是 idx = r*(bins*bins) + g*bins + b —— 与 `meshgrid(indexing='ij')`
|
||||
+ `reshape(-1,3)` 完全一致,所以立方体可以直接按这个公式索引。
|
||||
★ 轴序写错会静默换色:查表必须用 (R,G,B) 顺序,写成 (B,G,R) 颜色就全串了。
|
||||
"""
|
||||
g = _GRID_CACHE.get(step)
|
||||
if g is None:
|
||||
bins = 256 // step + 1
|
||||
ax = np.arange(bins, dtype=np.int32) * step
|
||||
grid = np.stack(np.meshgrid(ax, ax, ax, indexing='ij'), axis=-1).reshape(-1, 3)
|
||||
_GRID_CACHE[step] = (grid, bins)
|
||||
return _GRID_CACHE[step]
|
||||
return g
|
||||
|
||||
|
||||
def _lut_index(px, cols):
|
||||
"""像素 -> 最近调色板项,走粗查色立方 LUT。
|
||||
|
||||
step=8 → 33³ = 35937 个格子,单通道最大偏差 step/2 = 4 个色阶;
|
||||
对这些低对比度、本来就带抖动的画面完全够用(step=4 的 26 万格子慢 8 倍)。
|
||||
用 float32 算距离:int32 的 (35937, 256, 3) 中间数组会反复申请 ~100 MB,
|
||||
是之前把内存跑到 2.6 GB、整体跑十几分钟的元凶。
|
||||
"""
|
||||
grid, bins = _cube_grid(8)
|
||||
pal = np.asarray(cols, dtype=np.float32)
|
||||
best = np.empty(len(grid), dtype=np.uint8)
|
||||
gn = grid.astype(np.float32)
|
||||
chunk = 8192
|
||||
for s in range(0, len(grid), chunk):
|
||||
a = gn[s:s + chunk]
|
||||
d = ((a[:, None, :] - pal[None, :, :]) ** 2).sum(axis=2)
|
||||
best[s:s + chunk] = d.argmin(axis=1).astype(np.uint8)
|
||||
q = np.clip((px.astype(np.int32) + 4) >> 3, 0, bins - 1)
|
||||
flat = (q[:, 0] * bins + q[:, 1]) * bins + q[:, 2]
|
||||
return best[flat]
|
||||
|
||||
|
||||
def pad_to_usize(comp, raw_len):
|
||||
"""解码长度必须**正好 == usize**,否则设备端解码器直接判失败、整帧不画。
|
||||
|
||||
★ 本会话踩过的坑:库里 rle_compress 的输出曾比 usize 少 1 个字节(末尾少一条指令)。
|
||||
这里显式补「重复 N 次」token 把长度做满。补出来的余数可能是奇数(原厂 usize 就是
|
||||
1024+464*464+1 = 216321,比"调色板+像素"多 1),所以最后允许一条「重复 1 次」的
|
||||
2 字节指令;`00 00`(重复 0 次)也是合法空指令。
|
||||
"""
|
||||
short = USIZE - raw_len
|
||||
if short <= 0:
|
||||
return comp
|
||||
ex = bytearray()
|
||||
while short >= 2:
|
||||
k = min(short, 127)
|
||||
ex += bytes((k, 0))
|
||||
short -= k
|
||||
if short == 1:
|
||||
ex += bytes((1, 0))
|
||||
return comp + bytes(ex)
|
||||
|
||||
|
||||
def encode_cell(img, cell, qcache=None, qkey=None):
|
||||
"""(缩放, 颜色数, 固定) -> (RLE 流 bytes, 裸像素流 bytes)。不含 24 B 头。
|
||||
|
||||
qkey 要带上**缩放比例**:同一帧的不同缩放是不同的数组,
|
||||
★ 少了这个维度会让"不同颜色的表"永远命中不了缓存,每次都在 21.5 万像素上重算
|
||||
(第一版就是这样把内存跑到 1.7 GB、CPU 卡死的)。
|
||||
"""
|
||||
sc, ncol, fixed = cell
|
||||
rgb = scale_image(img, sc).convert('RGB')
|
||||
raw = quantize_arr(np.asarray(rgb), ncol, fixed, qcache,
|
||||
(qkey, sc) if qkey is not None else None)
|
||||
return pad_to_usize(L.rle_compress(raw), len(raw)), raw
|
||||
|
||||
|
||||
def raw_to_rgb(raw):
|
||||
"""把裸像素流还原成 RGB 图(用于"解出来的像素 == 量化结果"这条自证)。"""
|
||||
pal = bytearray(768)
|
||||
for i in range(256):
|
||||
pal[i * 3 + 0] = raw[i * 4 + 2]
|
||||
pal[i * 3 + 1] = raw[i * 4 + 1]
|
||||
pal[i * 3 + 2] = raw[i * 4 + 0]
|
||||
im = Image.frombytes('P', (FACE_W, FACE_H), raw[1024:1024 + FACE_W * FACE_H])
|
||||
im.putpalette(bytes(pal))
|
||||
return im.convert('RGB')
|
||||
|
||||
|
||||
def silhouette(raw):
|
||||
"""(非黑像素数, bbox) —— 用来挡住"字节完全自洽、内容却整片错位"这类 bug。
|
||||
|
||||
★ 这里就是因为只比字节、不比形状,差点放过去一版"斜条纹"图:
|
||||
当时 quantize 是按 472 宽取的像素,读成 464 宽 → 每行错 8 列 → 整幅图被剪成斜条纹。
|
||||
RLE 流本身完全正确、解出来的像素也和"量化结果"逐点一致,所以只有把
|
||||
"量化结果"再和**源图**比形状才抓得住。下面这个函数就是干这个的。
|
||||
"""
|
||||
a = np.asarray(raw_to_rgb(raw))
|
||||
m = a.max(axis=2) > 24
|
||||
n = int(m.sum())
|
||||
if not n:
|
||||
return 0, None
|
||||
ys, xs = np.nonzero(m)
|
||||
return n, (int(ys.min()), int(xs.min()), int(ys.max()), int(xs.max()))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 主流程
|
||||
def build():
|
||||
t0 = time.time()
|
||||
global USIZE, USIZE_EXTRA
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
stock, src = Romfs(TARGET_IMAGE), Romfs(SOURCE_IMAGE)
|
||||
if stock.volume != TARGET_VOLUME:
|
||||
raise SystemExit('目标镜像卷名是 %r,期望 %r —— 拿错分区了?'
|
||||
% (stock.volume, TARGET_VOLUME))
|
||||
slots, srcs = collect(stock), collect(src)
|
||||
tl, sl = sorted(slots), sorted(srcs)
|
||||
h0 = L.parse_header(slots[tl[0]][2])
|
||||
print('目标槽位 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % (
|
||||
len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576))
|
||||
print('源帧 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % (
|
||||
len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576))
|
||||
print('槽位格式 %dx%d cf=0x%02x flags=0x%02x stride=%d' % (
|
||||
h0['w'], h0['h'], h0['cf'], h0['flags'], h0['stride']))
|
||||
assert (h0['w'], h0['h']) == (FACE_W, FACE_H), '槽位不是 %dx%d' % (FACE_W, FACE_H)
|
||||
assert (h0['cf'], h0['flags']) == (0x0a, 0x08), '槽位不是 I8 + RLE'
|
||||
# ★ usize 从镜像里读出来,别写常量:原厂是 1024 + 464*464 + 1 = 216321
|
||||
n_usize = {struct.unpack('<III', slots[n][2][12:24])[2] for n in tl}
|
||||
print('原厂 usize 取值集合:%s' % sorted(n_usize))
|
||||
assert len(n_usize) == 1, '原厂 usize 不唯一:%s' % sorted(n_usize)
|
||||
USIZE = n_usize.pop()
|
||||
USIZE_EXTRA = USIZE - (1024 + FACE_W * FACE_H)
|
||||
print('usize = %d(= 1024 + %d*%d + %d)' % (USIZE, FACE_W, FACE_H, USIZE_EXTRA))
|
||||
assert USIZE_EXTRA == TAIL_EXTRA, \
|
||||
'尾部额外字节是 %d,与预期的 %d 不符 —— 先确认原厂流到底多几个字节' % (USIZE_EXTRA, TAIL_EXTRA)
|
||||
# 原厂 119 条都要真的能解出正好 usize
|
||||
bad_stock = [n for n in tl
|
||||
if len(L.rle_decompress(slots[n][2][24:24 + struct.unpack('<III', slots[n][2][12:24])[1]],
|
||||
1, None)) != USIZE]
|
||||
print('原厂 %d 条解码长度 == usize:%s' % (len(tl), not bad_stock))
|
||||
assert not bad_stock, '原厂就有解不出 usize 的槽位:%s' % bad_stock[:8]
|
||||
|
||||
# ---- 逐源帧解码 + 缩放 + 暗部归零(100 帧只做一次)----
|
||||
frames = {}
|
||||
for n in sl:
|
||||
_, im = L.decode_lvgl_bin(srcs[n][2])
|
||||
im = im.convert('RGBA').resize((FACE_W, FACE_H), Image.LANCZOS)
|
||||
frames[n] = {d: kill_dark(im, d) for d in DARK_LEVELS}
|
||||
print('解码 + 缩放 %d 帧:%.1fs' % (len(sl), time.time() - t0))
|
||||
|
||||
# 映射与 S4e 版一致:第 k 个槽位(k=0..118) <- q63 第 round(k/(119-1)*(100-1))+1 帧
|
||||
def qn_for(k):
|
||||
return sl[round(k / (len(tl) - 1) * (len(sl) - 1))]
|
||||
|
||||
cells = {} # (src_n, dark, ci) -> (comp, raw)
|
||||
src_sils = {} # (src_n, dark, ci) -> (非黑像素数, bbox)
|
||||
qcache = {}
|
||||
|
||||
def get_cell(src_n, dark, ci):
|
||||
key = (src_n, dark, ci)
|
||||
v = cells.get(key)
|
||||
if v is None:
|
||||
v = encode_cell(frames[src_n][dark], LADDER[ci], qcache, (src_n, dark))
|
||||
cells[key] = v
|
||||
src_sils[key] = silhouette(v[1])
|
||||
return v
|
||||
|
||||
def fits(src_n, dark, ci, csize):
|
||||
return len(get_cell(src_n, dark, ci)[0]) <= csize
|
||||
|
||||
# ---- 第一遍:逐槽量出「能塞进 csize 的最大阶梯档」----
|
||||
caps = []
|
||||
for k, n in enumerate(tl):
|
||||
csize = struct.unpack('<III', slots[n][2][12:24])[1]
|
||||
src_n = qn_for(k)
|
||||
best = None
|
||||
for dark in DARK_LEVELS:
|
||||
for ci in range(len(LADDER)):
|
||||
if fits(src_n, dark, ci, csize):
|
||||
best = (ci, dark)
|
||||
break
|
||||
if best:
|
||||
break
|
||||
caps.append(best if best else (CI_KEEP, DARK_LEVELS[0]))
|
||||
n_keep = sum(1 for c, _d in caps if c == CI_KEEP)
|
||||
print('第一遍完成:%d/%d 槽位能编出 ≤ csize 的流,%d 个连最省档都不行(耗时 %.0fs)'
|
||||
% (len(tl) - n_keep, len(tl), n_keep, time.time() - t0))
|
||||
|
||||
# ---- 平滑:从峰值向两侧取「运行最小值」----
|
||||
ordered = [c for c, _d in caps]
|
||||
peak = ordered.index(max(ordered))
|
||||
sm = list(ordered)
|
||||
for i in range(peak - 1, -1, -1):
|
||||
sm[i] = min(sm[i], sm[i + 1])
|
||||
for i in range(peak + 1, len(sm)):
|
||||
sm[i] = min(sm[i], sm[i - 1])
|
||||
jumps = [abs(sm[i + 1] - sm[i]) for i in range(len(sm) - 1)]
|
||||
print('阶梯曲线(每 6 帧采样):%s' % ' '.join(str(sm[i]) for i in range(0, len(sm), 6)))
|
||||
print('相邻帧最大跳变:%d 档' % (max(jumps) if jumps else 0))
|
||||
|
||||
# ---- 第二遍:以平滑档位为目标,在预算里挑**画面最大**的那一档;塞不下就往更省的档退 ----
|
||||
# 「挑画面最大」而不是「挑第一个能塞下的」:平滑只限制相邻帧的尺寸变化幅度,
|
||||
# 不该让它浪费槽位。实测按"第一个能塞下的"走,尾部几帧会把 7~12 万字节的 csize
|
||||
# 只用掉一半(全填 00 00 空指令),球体白白小一圈。
|
||||
records, degrade, kept, decode_ok, stat = [], [], [], 0, []
|
||||
for k, n in enumerate(tl):
|
||||
path, ino, blob = slots[n]
|
||||
method, csize, usize = struct.unpack('<III', blob[12:24])
|
||||
src_n = qn_for(k)
|
||||
start = sm[k]
|
||||
# 先按档位从优到劣扫一遍,记下所有塞得下的格子,再选缩放最大/颜色最多的那个
|
||||
best = None
|
||||
if start < CI_KEEP:
|
||||
for ci in range(start, len(LADDER)):
|
||||
for dark in DARK_LEVELS:
|
||||
if fits(src_n, dark, ci, csize):
|
||||
# 平滑约束:缩放不能比目标档大太多(否则相邻帧会忽大忽小),
|
||||
# 但可以更小;颜色数不受约束(只影响色阶,不影响尺寸)
|
||||
if LADDER[ci][0] <= LADDER[start][0] + SCALE_SLACK:
|
||||
sc_i, ncol_i, fx_i = LADDER[ci]
|
||||
key = (sc_i, ncol_i)
|
||||
if best is None or key > best[1]:
|
||||
best = ((ci, dark), key)
|
||||
rec_name = '%03d_%s_%d' % (len(records), path.rsplit('/', 1)[-1][:-4], n)
|
||||
if best is None:
|
||||
kept.append((n, csize))
|
||||
records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off,
|
||||
length=len(blob), data=blob, csize=csize, how='保留原厂',
|
||||
ci=None, dark=None, scale=1.0, colors=0, pad=0, enc=0))
|
||||
print(' !! 槽 %d(csize %d)塞不下 -> 保留原厂' % (n, csize))
|
||||
continue
|
||||
(ci, dark), _key = best
|
||||
comp, raw = get_cell(src_n, dark, ci)
|
||||
new = blob[:12] + struct.pack('<III', method, csize, usize) + L.pad_rle_safe(comp, csize)
|
||||
assert len(new) == len(blob), (len(new), len(blob))
|
||||
# ★ 就地自证三道:
|
||||
# ① 解出来正好 usize 字节(少一字节设备端就整帧不画)
|
||||
# ② 解出来的像素 == 量化结果(逐点)
|
||||
# ③ 量化结果的**形状**和源图在同一量级(挡住"字节自洽但内容错位"的 bug)
|
||||
# 第③条的容差:低色阶档位下球体边缘的暗像素会被并到最暗的那一档、显得略小
|
||||
# (4 色/2 色时尤其明显),所以只要求非黑像素数在 ±6% 内、bbox 每边差 ≤12 像素;
|
||||
# 真正"斜条纹"那种错位会让 bbox 直接铺满全画幅,这条一定抓得住。
|
||||
back = L.rle_decompress(new[24:24 + csize], 1, None)
|
||||
_, dec = L.decode_lvgl_bin(new)
|
||||
same = dec is not None and dec.convert('RGB').tobytes() == raw_to_rgb(raw).tobytes()
|
||||
src_sil = src_sils[(src_n, dark, ci)]
|
||||
got_sil = silhouette(raw)
|
||||
sil_ok = (src_sil[1] is not None and got_sil[1] is not None and
|
||||
abs(src_sil[0] - got_sil[0]) <= 0.06 * max(1, src_sil[0]) and
|
||||
max(abs(a - b) for a, b in zip(src_sil[1], got_sil[1])) <= 12)
|
||||
if len(back) == usize and same and sil_ok:
|
||||
decode_ok += 1
|
||||
else:
|
||||
print(' !! 槽 %d:解码 %d 字节(应 %d),像素一致=%s,形状合理=%s(%s vs %s)'
|
||||
% (n, len(back), usize, same, sil_ok, src_sil, got_sil))
|
||||
stat.append((n, csize, len(comp), csize - len(comp)))
|
||||
sc, ncol, fixed = LADDER[ci]
|
||||
how = ('固定%d色' % fixed if fixed is not None else '%d色' % ncol)
|
||||
how += '' if sc >= 0.995 else ' 缩放%.2f' % sc
|
||||
how += ' 暗部<%d' % dark
|
||||
if ci > start:
|
||||
degrade.append((n, csize, start, ci, how))
|
||||
how += ' [平滑目标第%d档放不下,退到第%d档]' % (start, ci)
|
||||
records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off,
|
||||
length=len(blob), data=new, csize=csize, how=how, ci=ci,
|
||||
dark=dark, scale=round(sc, 4), colors=ncol, fixed=fixed,
|
||||
pad=csize - len(comp), enc=len(comp), target_ci=start))
|
||||
|
||||
# ---- 写出镜像 + 清单 ----
|
||||
shutil.copyfile(TARGET_IMAGE, PATCHED)
|
||||
with open(PATCHED, 'r+b') as f:
|
||||
for r in records:
|
||||
f.seek(r['offset'])
|
||||
f.write(r['data'])
|
||||
manifest = dict(
|
||||
device='Xiaomi Watch S5 eSIM 46mm (p62lte)',
|
||||
partition='vela_health.bin volume=health device node=/dev/health',
|
||||
source='Xiaomi Watch S5 41mm (q63, v4.101.020) pressure/measure frames',
|
||||
target_dir=DIRP,
|
||||
mapping='第 k 个槽位(k=1..%d) <- q63 第 round((k-1)/(%d-1)*(%d-1))+1 帧(1..%d)'
|
||||
% (len(tl), len(tl), len(sl), len(sl)),
|
||||
face_wh=[FACE_W, FACE_H], slot_count=len(tl), source_count=len(sl), usize=USIZE,
|
||||
image_sha256=hashlib.sha256(stock.data).hexdigest(),
|
||||
ladder=[dict(ci=i, scale=c[0], colors=c[1], fixed=c[2]) for i, c in enumerate(LADDER)],
|
||||
records=[dict(name=r['name'], offset=r['offset'], length=r['length'],
|
||||
src=r['src'], how=r['how'], ci=r['ci'], dark=r['dark'],
|
||||
scale=r['scale'], colors=r['colors'], fixed=r['fixed'],
|
||||
pad=r['pad'], enc=r['enc'], target_ci=r['target_ci'],
|
||||
csize=r['csize']) for r in records],
|
||||
kept_stock=[dict(n=n, csize=c, name=slots[n][0]) for n, c in kept],
|
||||
degraded=[dict(n=n, csize=c, from_ci=a, to_ci=b, how=h) for n, c, a, b, h in degrade])
|
||||
json.dump(manifest, open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'),
|
||||
indent=1, ensure_ascii=False)
|
||||
|
||||
# ---- 汇总 ----
|
||||
print('\n阶梯使用情况(颜色数, 缩放, 暗部 -> 帧数):')
|
||||
used = {}
|
||||
for r in records:
|
||||
if r['ci'] is None:
|
||||
continue
|
||||
k2 = (r['colors'], r['scale'], r['dark'])
|
||||
used[k2] = used.get(k2, 0) + 1
|
||||
for kk, v in sorted(used.items(), key=lambda kv: (-kv[0][1], -kv[0][0])):
|
||||
print(' %3d 色 缩放%.2f 暗部<%d -> %3d 帧' % (kk[0], kk[1], kk[2], v))
|
||||
print(' 保留原厂:%d 帧 %s' % (len(kept), [n for n, _ in kept]))
|
||||
print(' 平滑目标档放不下、被迫退档:%d 帧 %s'
|
||||
% (len(degrade), [(n, h) for n, _c, _a, _b, h in degrade][:8]))
|
||||
pads = [p[3] for p in stat]
|
||||
print(' 槽位填充:实际用掉 %.1f%% 的 csize(填充 min %d / max %d / 平均 %.0f B,'
|
||||
'全是合法空指令 00 00 / 80)'
|
||||
% (100.0 * sum(p[2] for p in stat) / sum(p[1] for p in stat),
|
||||
min(pads), max(pads), sum(pads) / len(pads)))
|
||||
print(' 回解自证:正好 usize 且像素一致 %d / %d' % (decode_ok, len(records) - len(kept)))
|
||||
|
||||
# ---- 独立自证 ----
|
||||
r2 = Romfs(PATCHED)
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()]
|
||||
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in r2.entries()]
|
||||
print('\ninode 表逐位不变:%s(%d 条)' % (a == b, len(a)))
|
||||
print('superblock 一致:%s(size %d / cksum %08x)' % (
|
||||
stock.sb_size == r2.sb_size and stock.sb_cksum == r2.sb_cksum,
|
||||
r2.sb_size, r2.sb_cksum))
|
||||
print('镜像长度一致:%s(%d B)' % (len(stock.data) == len(r2.data), len(r2.data)))
|
||||
sh = open(PATCHED, 'rb').read()
|
||||
replay = all(sh[r['offset']:r['offset'] + r['length']] == r['data'] for r in records)
|
||||
print('回放(改后镜像 == 计划字节):%s' % replay)
|
||||
lens_ok = all(r['length'] == len(slots[int(r['name'].rsplit('_', 1)[1])][2]) for r in records)
|
||||
print('每条负载长度 == 原槽位长度:%s' % lens_ok)
|
||||
print('\n负载合计 %.2f MB;耗时 %.0fs;写出 %s'
|
||||
% (sum(r['length'] for r in records) / 1048576, time.time() - t0,
|
||||
os.path.join(OUTDIR, 'manifest.json')))
|
||||
return manifest
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
only = sys.argv[1] if len(sys.argv) > 1 else 'all'
|
||||
if only not in ('all', 'replace', 'restore'):
|
||||
raise SystemExit('用法: s5e_anim.py [all|replace|restore]')
|
||||
build()
|
||||
@@ -0,0 +1,312 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""两遍构建:表盘源码 + 资源 -> dist/S5ePAnim.face / dist/S5ePAnimRestore.face
|
||||
|
||||
main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符
|
||||
.work/overlay/<NAME> <- 中间产物(回填后的 Lua 落在这里)
|
||||
.work/build/<NAME> <- 编译工作区(Compiler.exe 的输入)
|
||||
dist/<NAME>.face <- 交付物
|
||||
|
||||
为什么两遍
|
||||
----------
|
||||
.face 里各资源的偏移取决于打包顺序;我们靠在 .face 里**搜字节**来定位它们。
|
||||
Lua 源码排在所有资源之后,所以回填(Lua 变长)不会移动前面的资源 —— 脚本最后逐条校验 0 漂移。
|
||||
|
||||
为什么最终产物要"再裸编一次"
|
||||
----------------------------
|
||||
两遍构建走的是 vendor 的 scripts/build_face.ps1,它在编译后调 set_face_id.ps1,
|
||||
把**头里第 5 字节写成 id 长度 10**,而固件自带表盘的 b5 是 0。
|
||||
所以交付物改从裸 `Compiler.exe -b` 的输出取(b5=0),只覆盖 40..49 = id。
|
||||
断言:裸产物与 toolchain 产物必须"只差 b5 + id 这 8 个字节",否则直接拒绝交付。
|
||||
|
||||
两份盘共用同一个 watchface id(462150102):装一份就顶掉另一份,设备上只占一份空间。
|
||||
"""
|
||||
import os, re, sys, json, shutil, hashlib, struct, subprocess
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from paths import PROJ, WORK, DIST, TEMPLATE, WF_ID, SCREEN, DEVICE_TYPE, PREVIEW
|
||||
import s5e_overlay as OV
|
||||
|
||||
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
|
||||
assert ROLE in ('replace', 'restore'), ROLE
|
||||
NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' # 路径/标识(不要改)
|
||||
TITLE = 'S5e Pt.2' # 手表上显示的表盘名(replace / restore 两张盘同名,须与 s5e_overlay.TITLE 一致)
|
||||
RECDIR = 'payload' if ROLE == 'replace' else 'payload_restore'
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(PROJ, 'main.lua')
|
||||
OVER = os.path.join(WORK, 'overlay', NAME)
|
||||
BUILD = os.path.join(WORK, 'build', NAME)
|
||||
RESOLVED = os.path.join(OVER, 'watchface', 'fprj', 'app', 'lua', 'main.lua')
|
||||
LOGLINES = []
|
||||
|
||||
|
||||
def log(s):
|
||||
print(s)
|
||||
LOGLINES.append(s)
|
||||
|
||||
|
||||
def write_lua(lua_text):
|
||||
"""把 Lua 写进编译工作区(两遍构建的唯一区别就在这里)。"""
|
||||
p = os.path.join(BUILD, 'watchface', 'fprj', 'app', 'lua', 'main.lua')
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
open(p, 'w', encoding='utf-8', newline='\n').write(lua_text)
|
||||
|
||||
|
||||
def prepare(lua_text, role):
|
||||
"""把官方模板拷成编译工作区,写 config / fprj / preview / lua / 资源。"""
|
||||
if not os.path.exists(TEMPLATE):
|
||||
raise SystemExit('找不到官方模板:%s\n请把 LuaDevTemplate-main 放到 vendor/ 下' % TEMPLATE)
|
||||
if os.path.exists(BUILD):
|
||||
shutil.rmtree(BUILD)
|
||||
shutil.copytree(TEMPLATE, BUILD)
|
||||
old = os.path.join(BUILD, 'watchface', 'fprj', 'LuaDevTemplate.fprj')
|
||||
if os.path.exists(old):
|
||||
os.remove(old)
|
||||
|
||||
cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3",
|
||||
"resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE",
|
||||
"input": "watchface/fprj/images/preview.png", "name": "preview"}}
|
||||
json.dump(cfg, open(os.path.join(BUILD, 'watchface.config.json'), 'w', encoding='utf-8'),
|
||||
indent=4, ensure_ascii=False)
|
||||
|
||||
fprj = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
|
||||
'<FaceProject DeviceType="%s">\r\n'
|
||||
' <Screen Title="%s" Bitmap="preview.png">\r\n'
|
||||
' <Widget Shape="34" Name="app_lua%%2Fmain.lua" X="0" Y="0" '
|
||||
'Width="%d" Height="%d" Alpha="0" />\r\n'
|
||||
' </Screen>\r\n</FaceProject>\r\n') % (DEVICE_TYPE, TITLE, SCREEN, SCREEN)
|
||||
open(os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj'), 'w',
|
||||
encoding='utf-16', newline='').write(fprj)
|
||||
|
||||
# 预览图 = 真实首页渲染 -> 圆形 -> 326x326(Compiler.exe 对 462 只收这个尺寸)
|
||||
prev = os.path.join(OVER, 'watchface', 'fprj', 'images', 'preview.png')
|
||||
OV.make_preview(prev)
|
||||
shutil.copyfile(prev, os.path.join(BUILD, 'watchface', 'fprj', 'images', 'preview.png'))
|
||||
|
||||
write_lua(lua_text)
|
||||
|
||||
imgdir = os.path.join(BUILD, 'watchface', 'fprj', 'app', 'images')
|
||||
for sub, src in (('pay', os.path.join(PROJ, 'blob_' + role)),
|
||||
('ui', os.path.join(PROJ, 'ui_images'))):
|
||||
if not os.path.isdir(src):
|
||||
raise SystemExit('缺少 %s/ —— 先跑 tools/s5e_payload.py %s' % (src, role))
|
||||
dst = os.path.join(imgdir, sub)
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
os.makedirs(dst)
|
||||
for fn in sorted(os.listdir(src)):
|
||||
shutil.copyfile(os.path.join(src, fn), os.path.join(dst, fn))
|
||||
|
||||
|
||||
def run_build():
|
||||
ps = os.path.join(BUILD, 'scripts', 'build_face.ps1')
|
||||
# 显式给 -FaceName:vendor 脚本默认用 config 的 projectName 当输出名,而 projectName 现在
|
||||
# 是**显示名**(S5e Pt.2),交付文件名必须还是 NAME.face。
|
||||
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps,
|
||||
'-FaceName', NAME + '.face'],
|
||||
capture_output=True, text=True, errors='replace')
|
||||
workspace_fprj() # vendor 可能把 .fprj 改名成显示名,编完改回 NAME.fprj
|
||||
out = ((r.stdout or '') + (r.stderr or '')).splitlines()
|
||||
for l in [x for x in out if x.strip()][-4:]:
|
||||
log(' | ' + l)
|
||||
face = os.path.join(BUILD, 'bin', NAME + '.face')
|
||||
if not os.path.exists(face):
|
||||
raise SystemExit('编译失败:没有产出 %s' % face)
|
||||
return open(face, 'rb').read()
|
||||
|
||||
|
||||
def workspace_fprj():
|
||||
"""把工作区里的 .fprj 归一成 NAME.fprj,并返回它的路径。
|
||||
|
||||
我们写下去时就是 NAME.fprj,但 vendor 的 build_face.ps1 会先跑
|
||||
sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S5e Pt.2)找
|
||||
<projectName>.fprj,找不到就把唯一那份改名过去(Title 也被改写成 projectName)。
|
||||
编完这里再改回 NAME.fprj —— 工作区里始终只有一份 .fprj(放两份会被 Compiler.exe 并成
|
||||
双倍资源,实测资源整体翻倍),文件名也保持项目标识不变。
|
||||
"""
|
||||
d = os.path.join(BUILD, 'watchface', 'fprj')
|
||||
fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj'))
|
||||
if len(fs) != 1:
|
||||
raise SystemExit('watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % fs)
|
||||
want = os.path.join(d, NAME + '.fprj')
|
||||
if fs[0] != NAME + '.fprj':
|
||||
os.replace(os.path.join(d, fs[0]), want)
|
||||
return want
|
||||
|
||||
|
||||
def compile_raw():
|
||||
"""裸 Compiler.exe —— 拿**忠实产物**(不经 vendor 的 set_face_id.ps1 改头)。
|
||||
|
||||
★ 为什么必须多这一步(b5=10 的根因)
|
||||
run_build() 走的是 vendor 的 build_face.ps1,它编完之后会调
|
||||
scripts/internal/set_face_id.ps1,那个脚本里有一句
|
||||
$bytes[5] = [byte]$faceIdSize # = 10
|
||||
也就是把「id 字符串长度」写进了头里第 5 字节。固件自带表盘的 b5 是 0,
|
||||
交付物照着 0 走;所以最终产物必须取自**裸编译输出**(b5 由编译器给,实测 0),
|
||||
然后只把 40..49 覆盖成我们的 id,b5 一个字节都不碰。
|
||||
(s5e-icons 的 s5e_build.py 就是 compile_raw() + 补 id 这一套,真机验证过。)
|
||||
"""
|
||||
exe = os.path.join(BUILD, 'watchface', 'tools', 'Compiler.exe')
|
||||
fprj = workspace_fprj()
|
||||
out = os.path.join(BUILD, 'raw')
|
||||
if os.path.exists(out):
|
||||
shutil.rmtree(out)
|
||||
os.makedirs(out)
|
||||
r = subprocess.run([exe, '-b', fprj, out, NAME + '.face', WF_ID],
|
||||
capture_output=True, text=True, errors='replace')
|
||||
p = os.path.join(out, NAME + '.face')
|
||||
if not os.path.exists(p):
|
||||
raise SystemExit('裸编译失败:%s' % ((r.stdout or '') + (r.stderr or ''))[-400:])
|
||||
return open(p, 'rb').read()
|
||||
|
||||
|
||||
def hits(face, blob):
|
||||
out, s = [], 0
|
||||
while True:
|
||||
i = face.find(blob, s)
|
||||
if i < 0:
|
||||
return out
|
||||
out.append(i)
|
||||
s = i + 1
|
||||
|
||||
|
||||
UI_NAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback',
|
||||
'ic_qq', 'logo']
|
||||
|
||||
|
||||
def locate(face, blobs, pay_blob, fp_blob):
|
||||
"""量出 UI 图片 / 负载 / 指纹在 .face 里的偏移,并逐条字节校验。"""
|
||||
ui, bad = [], 0
|
||||
for name in UI_NAMES:
|
||||
blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read()
|
||||
h = hits(face, blob)
|
||||
if len(h) != 1:
|
||||
log(' !! UI %s 命中 %d 次(应为 1)' % (name, len(h)))
|
||||
bad += 1
|
||||
if h:
|
||||
ui.append((h[0], len(blob), name))
|
||||
if bad:
|
||||
raise SystemExit('有 UI 资源无法唯一定位')
|
||||
ml, mlf = blobs['magic_len'], blobs['magic_fp_len']
|
||||
if face.count(pay_blob[:ml]) != 1:
|
||||
raise SystemExit('payload 魔数命中 %d 次(应为 1)' % face.count(pay_blob[:ml]))
|
||||
if face.count(fp_blob[:mlf]) != 1:
|
||||
raise SystemExit('fp 魔数命中 %d 次(应为 1)' % face.count(fp_blob[:mlf]))
|
||||
pay_base = face.find(pay_blob)
|
||||
fp_base = face.find(fp_blob)
|
||||
if pay_base < 0:
|
||||
raise SystemExit('payload 不是原样连续存放')
|
||||
if fp_base < 0:
|
||||
raise SystemExit('fp 不是原样连续存放')
|
||||
# 用生成负载时的 sha256 逐条独立校验(避免"自己比自己"这种恒真检查)
|
||||
for tag, base, path, key, mag in (
|
||||
('pay', pay_base, os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'),
|
||||
'pay_sha', ml),
|
||||
('fp', fp_base, os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'fp_sha', mlf)):
|
||||
data = open(path, 'rb').read()
|
||||
if face.find(data) != base:
|
||||
raise SystemExit('%s 文件不是原样连续存放' % tag)
|
||||
for i, r in enumerate(blobs['records']):
|
||||
res = base + mag + (r['rel'] if tag == 'pay' else r['fprel'])
|
||||
ln = r['length'] if tag == 'pay' else r['fplen']
|
||||
seg = face[res:res + ln]
|
||||
if len(seg) != ln or hashlib.sha256(seg).hexdigest() != r[key]:
|
||||
raise SystemExit('%s[%d] 在 0x%x 处字节不吻合' % (tag, i, res))
|
||||
pay = [(pay_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']]
|
||||
rb = [(fp_base + mlf + r['fprel'], r['target'], r['fplen']) for r in blobs['records']]
|
||||
return ui, pay, rb, pay_base, fp_base
|
||||
|
||||
|
||||
def main():
|
||||
tpl = open(SRC, encoding='utf-8').read()
|
||||
for ph in ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__'):
|
||||
if ph not in tpl:
|
||||
raise SystemExit('main.lua 里没有占位符 %s —— 它应该是源码模板' % ph)
|
||||
log('=== %s(role=%s,id=%s)===' % (NAME, ROLE, WF_ID))
|
||||
blobs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'),
|
||||
encoding='utf-8'))
|
||||
for r in blobs['records']:
|
||||
if 'pay_sha' not in r or 'fp_sha' not in r:
|
||||
raise SystemExit('offsets.json 里没有 sha256 —— 请重跑 tools/s5e_payload.py %s' % ROLE)
|
||||
pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read()
|
||||
fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read()
|
||||
ml, mlf = blobs['magic_len'], blobs['magic_fp_len']
|
||||
|
||||
log('第 1 遍:用占位符编一次,量出资源在 .face 里的位置')
|
||||
prepare(tpl, ROLE)
|
||||
face1 = run_build()
|
||||
log(' face %d bytes' % len(face1))
|
||||
ui, pay, rb, pay_base, fp_base = locate(face1, blobs, pay_blob, fp_blob)
|
||||
for o, l, n in ui:
|
||||
log(' %-12s off=0x%06x len=%d' % (n, o, l))
|
||||
log(' payload base=0x%06x(%d B) / fp base=0x%06x(%d B),共 %d 条记录'
|
||||
% (pay_base, len(pay_blob), fp_base, len(fp_blob), len(pay)))
|
||||
|
||||
ui_txt = '\n'.join(' {%d,%d,"%s"},' % (o, l, n) for o, l, n in ui)
|
||||
pay_txt = ' pay = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in pay) + '\n },'
|
||||
rb_txt = ' rb = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in rb) + '\n },'
|
||||
resolved = (tpl.replace('__UIIMG__', ui_txt).replace('__PAY__', pay_txt)
|
||||
.replace('__RB__', rb_txt).replace('__ROLE__', ROLE))
|
||||
assert '__UIIMG__' not in resolved and '__ROLE__' not in resolved
|
||||
assert '__PAY__' not in resolved and '__RB__' not in resolved
|
||||
os.makedirs(os.path.dirname(RESOLVED), exist_ok=True)
|
||||
open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved)
|
||||
log('第 2 遍:回填后的 Lua(%d bytes)重新编译' % len(resolved))
|
||||
# ★ 第 1 遍编的是**占位符版**;第 2 遍必须把回填后的 Lua 写回编译工作区再编,
|
||||
# 否则交付物里还是 __PAY__/__RB__/__ROLE__(表盘在设备上直接"负载表为空")。
|
||||
write_lua(resolved)
|
||||
face2 = run_build()
|
||||
|
||||
# ---- 0 漂移校验 ----
|
||||
drift = 0
|
||||
ui2, pay2, rb2, pb2, fb2 = locate(face2, blobs, pay_blob, fp_blob)
|
||||
if ui2 != ui:
|
||||
log(' !! UI 偏移漂移'); drift += 1
|
||||
if (pb2, fb2) != (pay_base, fp_base):
|
||||
log(' !! 负载偏移漂移'); drift += 1
|
||||
if (pay2, rb2) != (pay, rb):
|
||||
log(' !! 负载表内容漂移'); drift += 1
|
||||
files = [(f, open(os.path.join(PROJ, RECDIR, f), 'rb').read())
|
||||
for f in sorted(os.listdir(os.path.join(PROJ, RECDIR))) if f.endswith('.bin')]
|
||||
ok = sum(1 for off, tgt, ln in pay
|
||||
if any(len(x) == ln and face2[off:off + ln] == x for _n, x in files))
|
||||
if ok != len(pay):
|
||||
log(' !! pay 只有 %d/%d 条与 %s/ 吻合' % (ok, len(pay), RECDIR)); drift += 1
|
||||
log('偏移校验:%s' % ('全部吻合,0 漂移' if drift == 0 else '!! %d 处异常' % drift))
|
||||
|
||||
# ---- 最终产物:id 必须逐字节正确(b5=0,40..49 = id + NUL)----
|
||||
# 交付物取**裸编译**的忠实产物:b5 保持编译器给的值(实测 0),只覆盖 id 那 10 字节。
|
||||
# (vendor 的 build_face.ps1 会顺手把 b5 写成 id 长度 10,不能拿它当交付物。)
|
||||
raw = compile_raw()
|
||||
if raw[0:4] != b'\x5a\xa5\x34\x12':
|
||||
raise SystemExit('bad face magic')
|
||||
nd = [i for i in range(min(len(raw), len(face2))) if raw[i] != face2[i]]
|
||||
stray = [i for i in nd if not (i == 5 or 40 <= i < 50)]
|
||||
if len(raw) != len(face2) or stray:
|
||||
raise SystemExit('忠实产物与 toolchain 产物不是"只差 b5 + id":'
|
||||
'len %d vs %d,差异 %d 字节,越界位置 %s'
|
||||
% (len(raw), len(face2), len(nd), stray[:8]))
|
||||
if raw[5] != 0:
|
||||
raise SystemExit('裸编译产物的 b5 = %d(期望 0)—— 设备表盘的约定变了,得重查' % raw[5])
|
||||
d = bytearray(raw)
|
||||
d[40:50] = WF_ID.encode() + b'\x00' * (10 - len(WF_ID))
|
||||
os.makedirs(DIST, exist_ok=True)
|
||||
out = os.path.join(DIST, NAME + '.face')
|
||||
open(out, 'wb').write(bytes(d))
|
||||
a32, = struct.unpack('<I', bytes(d[32:36]))
|
||||
pal, pw, ph, psz = struct.unpack('<IHHI', bytes(d[a32:a32 + 12]))
|
||||
log('忠实产物 vs toolchain:%d 字节差异(只允许 b5 + id 这 8 个位置:%s)'
|
||||
% (len(nd), nd))
|
||||
log('b4=%d b5=%d id=%r' % (d[4], d[5], bytes(d[40:50])))
|
||||
log('preview @0x%x: %dx%d palette=%d data=%d' % (a32, pw, ph, pal, psz))
|
||||
if (pw, ph) != (PREVIEW, PREVIEW):
|
||||
raise SystemExit('预览块尺寸 %dx%d 不是 %dx%d' % (pw, ph, PREVIEW, PREVIEW))
|
||||
if d[5] != 0 or bytes(d[40:50]) != WF_ID.encode() + b'\x00' * (10 - len(WF_ID)):
|
||||
raise SystemExit('交付物头不对:b5=%d id=%r' % (d[5], bytes(d[40:50])))
|
||||
log('交付物:%s %d B sha256 %s' % (out, len(d), hashlib.sha256(bytes(d)).hexdigest()))
|
||||
if drift:
|
||||
raise SystemExit('有偏移漂移,拒绝交付')
|
||||
open(os.path.join(WORK, 'build_log_%s.txt' % ROLE), 'w',
|
||||
encoding='utf-8').write('\n'.join(LOGLINES))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""圆屏切边检查:用**真实渲染出来的像素**判 480x480 圆屏有没有被切到。
|
||||
|
||||
圆心 (240,240)、半径 240。任何 alpha>8 且离圆心 >240 的像素都算越界。
|
||||
"""
|
||||
import os, sys, math
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import s5e_render_ui as R
|
||||
from s5e_pages import PAGES
|
||||
|
||||
W = H = 480
|
||||
CX = CY = 240.0
|
||||
RAD = 240.0
|
||||
|
||||
print('%-10s %8s %11s worst point' % ('page', 'max r', 'outside px'))
|
||||
print('-' * 62)
|
||||
bad = 0
|
||||
for name, items in PAGES.items():
|
||||
im = R.render_raw(items)
|
||||
px = im.load()
|
||||
worst, wpt, outside = 0.0, None, 0
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
if px[x, y][3] > 8:
|
||||
dd = math.hypot(x - CX, y - CY)
|
||||
if dd > worst:
|
||||
worst, wpt = dd, (x, y)
|
||||
if dd > RAD:
|
||||
outside += 1
|
||||
print('%-10s %8.1f %11d %s%s' % (name, worst, outside, wpt,
|
||||
'' if worst <= RAD else ' <-- CLIPPED'))
|
||||
if outside:
|
||||
bad += 1
|
||||
print('-' * 62)
|
||||
print('圆心 (240,240) 半径 240 画布 480x480 越界页数:%d' % bad)
|
||||
if R.OVERFLOW:
|
||||
print('!! 文本溢出框 %d 处:' % len(R.OVERFLOW))
|
||||
for o in R.OVERFLOW:
|
||||
print(' ', o)
|
||||
sys.exit(1 if (bad or R.OVERFLOW) else 0)
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""main.lua 的静态体检 —— 编译前把「真机上才会炸」的几类错抓出来。
|
||||
|
||||
S4e 那版的真机事故是:`segs/ddWrite/ddRead` 被放在 `slurp/sh` **之前**,
|
||||
于是函数体里引用的 `sh` 不是那个 local,而是**全局 nil**,一调用就
|
||||
`attempt to call a nil value (global 'sh')` —— 而且当时整条流水线全绿
|
||||
(Compiler.exe 只打包 Lua、不解析它)。所以本文件专门查:
|
||||
|
||||
① 块配平(function/if/for/while/do/end/repeat/until)
|
||||
② **先用后声明**:某个名字在文件里是 `local`,但第一次调用发生在它声明之前
|
||||
③ 调用了既不是 local、也不在 Lua 标准库白名单里的名字(拼写错 / 忘了 local)
|
||||
④ 模板约定:4 个占位符各 1 次、`--[[` 只在文件头、以 `return ui\\n` 结尾
|
||||
(tools/s5e_verify.py 会从 .face 里反解 Lua,这几条是它定位 Lua 的前提)
|
||||
|
||||
用法:python tools/s5e_lua_static.py [main.lua]
|
||||
"""
|
||||
import os, re, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from paths import PROJ
|
||||
from lua_balance import strip
|
||||
|
||||
LUAC = os.path.join(PROJ, 'main.lua')
|
||||
|
||||
# Lua 5.4 标准库 + 本项目用到的宿主全局(都不是拼写错)
|
||||
WHITELIST = set("""
|
||||
require pcall xpcall type pairs ipairs next select tostring tonumber rawget rawset
|
||||
rawequal rawlen setmetatable getmetatable error assert print unpack load
|
||||
string table math io os coroutine utf8 debug collectgarbage
|
||||
""".split())
|
||||
KEYWORDS = set("""
|
||||
and break do else elseif end false for function goto if in local nil not or
|
||||
repeat return then true until while
|
||||
""".split())
|
||||
REQUIRED_PH = ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__')
|
||||
|
||||
fail = 0
|
||||
|
||||
|
||||
def bad(msg):
|
||||
global fail
|
||||
fail += 1
|
||||
print(' FAIL ' + msg)
|
||||
|
||||
|
||||
def ck(cond, msg):
|
||||
print((' OK ' if cond else ' FAIL ') + msg)
|
||||
if not cond:
|
||||
global fail
|
||||
fail += 1
|
||||
|
||||
|
||||
def lineno(src, idx):
|
||||
return src.count('\n', 0, idx) + 1
|
||||
|
||||
|
||||
def decls(src, code):
|
||||
"""名字 -> 它在 code 里第一次被 local 声明(或作为函数参数出现)的位置。"""
|
||||
out = {}
|
||||
for m in re.finditer(r'\blocal\s+function\s+([A-Za-z_]\w*)', code):
|
||||
out.setdefault(m.group(1), m.start(1))
|
||||
for m in re.finditer(r'\blocal\s+([A-Za-z_][\w,\s]*?)\s*(?:=|$)', code, re.M):
|
||||
base = m.start(1)
|
||||
for piece in m.group(1).split(','):
|
||||
nm = piece.strip()
|
||||
if re.fullmatch(r'[A-Za-z_]\w*', nm):
|
||||
out.setdefault(nm, base)
|
||||
# 函数参数也是 local(`function(a, b)` / `function f(a)`),否则会误报
|
||||
for m in re.finditer(r'\bfunction\b\s*[A-Za-z_.:]*\s*\(([^)]*)\)', code):
|
||||
for piece in m.group(1).split(','):
|
||||
nm = piece.strip()
|
||||
if re.fullmatch(r'[A-Za-z_]\w*', nm):
|
||||
out.setdefault(nm, m.start())
|
||||
return out
|
||||
|
||||
|
||||
def calls(code):
|
||||
"""所有函数调用点(排除 `function NAME(` 这种定义)。"""
|
||||
out = []
|
||||
for m in re.finditer(r'(?<![\w.:])([A-Za-z_]\w*)\s*\(', code):
|
||||
head = code[max(0, m.start(1) - 12):m.start(1)]
|
||||
if re.search(r'\bfunction\s+$', head):
|
||||
continue # 定义,不是调用
|
||||
out.append((m.group(1), m.start(1)))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
src = open(LUAC, encoding='utf-8').read()
|
||||
code = strip(src)
|
||||
print('=== tools/s5e_lua_static.py %s (%d B) ===' % (LUAC, len(src.encode('utf-8'))))
|
||||
|
||||
print('\n[1] 块配平')
|
||||
def cnt(k):
|
||||
return len(re.findall(r'(?<![\w.])' + k + r'(?![\w])', code))
|
||||
do_standalone = cnt('do') - cnt('while') - cnt('for')
|
||||
openers = cnt('function') + cnt('if') + cnt('while') + cnt('for') + do_standalone
|
||||
ck(openers == cnt('end') and cnt('repeat') == cnt('until'),
|
||||
'openers=%d end=%d(function=%d if=%d for=%d while=%d do=%d)repeat=%d until=%d'
|
||||
% (openers, cnt('end'), cnt('function'), cnt('if'), cnt('for'), cnt('while'),
|
||||
do_standalone, cnt('repeat'), cnt('until')))
|
||||
|
||||
print('\n[2] 先用后声明(真机上会变成 "call a nil value")')
|
||||
d = decls(src, code)
|
||||
early, unknown = [], []
|
||||
for name, pos in calls(code):
|
||||
if name in KEYWORDS or name in WHITELIST:
|
||||
continue
|
||||
if name in d:
|
||||
if pos < d[name]:
|
||||
early.append((name, lineno(src, pos), lineno(src, d[name])))
|
||||
else:
|
||||
unknown.append((name, lineno(src, pos)))
|
||||
ck(not early, '没有"先调用后 local":%s'
|
||||
% ('' if not early else ' / '.join('%s 调用@%d 声明@%d' % e for e in early)))
|
||||
ck(not unknown, '没有未声明的被调用名:%s'
|
||||
% ('' if not unknown else ' / '.join('%s@%d' % u for u in unknown)))
|
||||
|
||||
print('\n[3] 模板约定(s5e_verify.py 靠这些从 .face 里反解 Lua)')
|
||||
for ph in REQUIRED_PH:
|
||||
ck(src.count(ph) == 1, '占位符 %s 出现 %d 次(要 1)' % (ph, src.count(ph)))
|
||||
ck(src.startswith('--[[--'), '文件以 --[[-- 开头')
|
||||
ck(src.count('--[[') == 1, '--[[ 只出现 %d 次(要 1,否则 verify 会从中间截 Lua)'
|
||||
% src.count('--[['))
|
||||
ck(src.endswith('return ui\n'), '以 return ui\\n 结尾')
|
||||
ck(src.count('return ui\n') == 1, 'return ui\\n 只出现 %d 次(要 1)' % src.count('return ui\n'))
|
||||
ck('local UIIMG = {' in src, '有 local UIIMG = {')
|
||||
|
||||
print('\n[4] 与 s5e_verify.py 对齐的字面量')
|
||||
ck(re.search(r"local ROLE = '__ROLE__'", src) is not None, "local ROLE = '__ROLE__'")
|
||||
ck(re.search(r'local W, H = 480, 480', src) is not None, 'local W, H = 480, 480')
|
||||
m = re.search(r'local DEVS = \{([^}]*)\}', src)
|
||||
ck(m is not None and '/dev/health' in m.group(1),
|
||||
'DEVS 里有 health -> /dev/health:%s' % (m.group(1).strip() if m else None))
|
||||
ck("local DEV = DEVS.health" in src,
|
||||
'DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设)')
|
||||
paytbl = re.search(r'local PAYTBL = \{\n__PAY__\n__RB__\n\}', src)
|
||||
ck(paytbl is not None, 'PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名)')
|
||||
ck('466' not in src, '没有残留的 466(旧 S4e 尺寸)')
|
||||
|
||||
print('\n=== %s ===' % ('全部通过' if not fail else '%d 项失败' % fail))
|
||||
return 1 if fail else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""为官方 LuaDevTemplate 生成可编译的 overlay。
|
||||
|
||||
两份盘(role):
|
||||
replace -> S5ePAnim 负载 = q63 的帧
|
||||
restore -> S5ePAnimRestore 负载 = 原厂字节
|
||||
两份盘**共用同一个 watchface id**(462150102):装一份就顶掉另一份,设备上只占一份空间。
|
||||
|
||||
要点(都是踩过的坑):
|
||||
* preview.png 必须是 326x326 —— Compiler.exe 对 DeviceType 462 只收这个尺寸
|
||||
* fprj 必须是 UTF-16(带 BOM)且用 CRLF,否则 Compiler.exe 直接拒绝
|
||||
* 表盘界面是 480x480(Screen Width/Height),预览图只是表盘列表里的缩略图
|
||||
* 负载只放 blob_<role>/ 那一份「合并后的单条目」
|
||||
"""
|
||||
import os, sys, json, shutil
|
||||
from PIL import Image
|
||||
from paths import PROJ, WORK, SCREEN, PREVIEW, WF_ID, DEVICE_TYPE
|
||||
from s5e_pages import PAGES
|
||||
import s5e_render_ui as R
|
||||
|
||||
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
|
||||
assert ROLE in ('replace', 'restore'), ROLE
|
||||
NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' # 路径/标识(不要改)
|
||||
TITLE = 'S5e Pt.2' # 手表上显示的表盘名(两份盘同一个名字)= fprj 的 <Screen Title>
|
||||
OUT = os.path.join(WORK, 'overlay')
|
||||
ENTRY_WIDGET = 'app_lua%2Fmain.lua'
|
||||
|
||||
|
||||
def make_preview(path):
|
||||
"""480 首页 -> 圆形 alpha -> 326x326(Compiler.exe 只收这个尺寸)。"""
|
||||
R.W = R.H = SCREEN
|
||||
img = R.render_raw(PAGES['home'])
|
||||
base = Image.new('RGBA', (SCREEN, SCREEN), (0, 0, 0, 255))
|
||||
base.alpha_composite(img)
|
||||
mask = Image.new('L', (SCREEN, SCREEN), 0)
|
||||
from PIL import ImageDraw
|
||||
ImageDraw.Draw(mask).ellipse([0, 0, SCREEN - 1, SCREEN - 1], fill=255)
|
||||
base.putalpha(mask)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
base.resize((PREVIEW, PREVIEW), Image.LANCZOS).save(path)
|
||||
|
||||
|
||||
def write_fprj(path, title):
|
||||
xml = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
|
||||
'<FaceProject DeviceType="%s">\r\n'
|
||||
' <Screen Title="%s" Bitmap="preview.png">\r\n'
|
||||
' <Widget Shape="34" Name="%s" X="0" Y="0" '
|
||||
'Width="%d" Height="%d" Alpha="0" />\r\n'
|
||||
' </Screen>\r\n</FaceProject>\r\n'
|
||||
% (DEVICE_TYPE, title, ENTRY_WIDGET, SCREEN, SCREEN))
|
||||
open(path, 'w', encoding='utf-16', newline='').write(xml)
|
||||
|
||||
|
||||
def build(name, role):
|
||||
base = os.path.join(OUT, name)
|
||||
if os.path.exists(base):
|
||||
shutil.rmtree(base)
|
||||
fprj = os.path.join(base, 'watchface', 'fprj')
|
||||
os.makedirs(os.path.join(fprj, 'images'), exist_ok=True)
|
||||
os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True)
|
||||
|
||||
cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3",
|
||||
"resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE",
|
||||
"input": "watchface/fprj/images/preview.png", "name": "preview"}}
|
||||
json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'),
|
||||
indent=4, ensure_ascii=False)
|
||||
write_fprj(os.path.join(fprj, name + '.fprj'), TITLE)
|
||||
make_preview(os.path.join(fprj, 'images', 'preview.png'))
|
||||
shutil.copyfile(os.path.join(PROJ, 'main.lua'),
|
||||
os.path.join(fprj, 'app', 'lua', 'main.lua'))
|
||||
blob = os.path.join(PROJ, 'blob_' + role)
|
||||
if not os.path.isdir(blob):
|
||||
raise SystemExit('缺少 blob_%s/ —— 先跑 tools/s5e_payload.py %s' % (role, role))
|
||||
for src, rel in ((blob, 'pay'), (os.path.join(PROJ, 'ui_images'), 'ui')):
|
||||
dst = os.path.join(fprj, 'app', 'images', rel)
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
return base
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
build(NAME, ROLE)
|
||||
print('overlay written to', os.path.join(OUT, NAME))
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Xiaomi Watch S5 eSIM (p62lte) 480x480 页面规格。
|
||||
|
||||
坐标系就是 `main.lua` 用的那一套;`s5e_render_ui.py` / `s5e_check_clip.py` 画的是同一份规格,
|
||||
所以预览图是"诚实"的(不是另画一套)。与 S5 图标包那份 480 设计稿同一套语言,
|
||||
只是页面文案改成压力动画替换器。
|
||||
"""
|
||||
import os
|
||||
|
||||
from paths import PROJ, DOCS, ASSETS
|
||||
W = H = 480
|
||||
BG = (0, 0, 0)
|
||||
GRAY = '999999'
|
||||
WHITE = 'ffffff'
|
||||
PLATE = '002927'
|
||||
CIRCLE = '4f4f4f'
|
||||
OK = '43cf7c'
|
||||
ERR = 'd43030'
|
||||
S600, S500, S400 = 'semibold', 'medium', 'regular'
|
||||
|
||||
# 列表项几何
|
||||
# radius=40 不是审美选择:y=88 那一行离圆心 152px,圆屏在那里的弦半宽只有 √(240²-152²)=185.8,
|
||||
# 也就是该行 x 只能在 54.2..425.8 之内。底板是 x=50 w=379,圆角不够大时四个角会越过圆边
|
||||
# (r=35 时角点半径 240.7 → check_clip 记为越界)。r=40 正好把角收进去。
|
||||
ITEM_X, ITEM_W, ITEM_H, ITEM_R = 50, 379, 91, 40
|
||||
ICONC_DX, ICONC_DY, ICONC_SZ = 21, 20, 52
|
||||
ICON_DX, ICON_DY, ICON_SZ = 31, 30, 31
|
||||
TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 82, 26, 214, 42
|
||||
ICON_PNG = {'ic_help': 'MdiLightbulbOutline.png', 'ic_replace': 'MdiBandage.png',
|
||||
'ic_about': 'MdiInformationOutline.png', 'ic_check': 'MdiClipboardTextSearchOutline.png',
|
||||
'ic_rollback': 'MdiReplyAllOutline.png', 'ic_qq': 'qq.png'}
|
||||
|
||||
|
||||
def item(y, iconkey, title):
|
||||
return [
|
||||
dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R),
|
||||
dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY, w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE),
|
||||
dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY, w=ICON_SZ, h=ICON_SZ,
|
||||
src=os.path.join(ASSETS, ICON_PNG[iconkey])),
|
||||
dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY, w=TITLE_W, h=TITLE_H,
|
||||
text=title, size=29, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
]
|
||||
|
||||
|
||||
def clock(t='10:24'):
|
||||
return dict(kind='label', x=201, y=4, w=78, h=40, text=t, size=29,
|
||||
weight=S600, color=GRAY, align='left', vtop=True)
|
||||
|
||||
|
||||
def title(text, x, w=260):
|
||||
return dict(kind='label', x=x, y=36, w=w, h=42, text=text, size=31,
|
||||
weight=S500, color=WHITE, align='left', vtop=True)
|
||||
|
||||
|
||||
def back_arrow(x=167):
|
||||
return dict(kind='image', x=x, y=44, w=18, h=29, src=os.path.join(ASSETS, 'backarrow.png'))
|
||||
|
||||
|
||||
def big_button(y, text):
|
||||
return [
|
||||
dict(kind='rect', x=111, y=y, w=258, h=82, bg=PLATE, radius=41),
|
||||
dict(kind='label', x=182, y=y + 22, w=126, h=40, text=text, size=29,
|
||||
weight=S500, color=WHITE, align='left', vtop=True),
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 页面
|
||||
# 所有文字的像素宽度都用真 MiSans 量过(tools/s5e_render_ui.py 同一套字体):
|
||||
# 31px: 'S5e Pt.2'=125 '快速帮助'/'开始替换'/'正在替换'=124 '关于'=62
|
||||
# 29px: '确认替换'/'重启手表'=116 '环境预检查'=145 '732681995'=155
|
||||
# 25px: '检查 health 分区与负载完整性。'=369 '恢复 119 帧动画的原厂样式。'=331
|
||||
# '预检查通过 | 119 条 | 15.49MB'=349 'Xiaomi Watch S5 eSIM 46mm'=367
|
||||
# 圆屏在高度 y 处的弦半宽 = √(240² - (y-240)²),所以**越靠下的行越窄** ——
|
||||
# 型号那行原来放在 y=229(弦半宽只有 171),'Xiaomi Watch S5 eSIM 46mm' 宽 367 直接被切掉。
|
||||
# 现在把该行拆成两行、整体上移到 y=195 / 228,两行都短于那里的弦。
|
||||
PAGE_HOME = [clock(), title('S5e Pt.2', 177, 127),
|
||||
*item(88, 'ic_help', '快速帮助'),
|
||||
*item(186, 'ic_replace', '开始替换'),
|
||||
*item(285, 'ic_about', '关于')]
|
||||
|
||||
_HELP_HEAD = [clock(), back_arrow(167), title('快速帮助', 188, 124)]
|
||||
# 说明文字必须**单行**放下(折行后第 2 行会越出圆屏,check_clip 抓过):
|
||||
# y=182 处圆屏弦半宽 = √(240²-58²) = 232.9,x 从 64 起时最多能到 64+232.9 = 296.9...
|
||||
# 实际用 380 的框宽是安全的(文字宽 354px < 380,且落在 64..444 内)。
|
||||
_HELP_D1 = dict(kind='label', x=64, y=182, w=380, h=35,
|
||||
text='检查 health 分区与负载完整性。', size=24,
|
||||
weight=S500, color=GRAY, align='left', vtop=True)
|
||||
_HELP_I2 = item(264, 'ic_rollback', '回滚修改')
|
||||
_HELP_D2 = dict(kind='label', x=67, y=360, w=348, h=35,
|
||||
text='恢复 119 帧动画的原厂样式。', size=25,
|
||||
weight=S500, color=GRAY, align='left', vtop=True)
|
||||
PAGE_HELP = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [_HELP_D1] + _HELP_I2 + [_HELP_D2]
|
||||
|
||||
# 预检查结果:最多 2 行(24px 行高 33,框高 76 >= 2*33)
|
||||
HELP_DESC_OK = dict(kind='label', x=67, y=182, w=348, h=76,
|
||||
text='预检查通过 | 119 条 | 15.49MB\nhealth 分区可读。',
|
||||
size=24, weight=S500, color=OK, align='left', vtop=True)
|
||||
HELP_DESC_ERR = dict(kind='label', x=67, y=182, w=348, h=76,
|
||||
text='预检查失败:设备节点读不到,\n或分区头不是 ROMFS。',
|
||||
size=24, weight=S500, color=ERR, align='left', vtop=True)
|
||||
PAGE_HELP_OK = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2]
|
||||
PAGE_HELP_ERR = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2]
|
||||
|
||||
# 警告页:每行单独一个框(h=34 ≈ 25px 真行高 35),行距 34。
|
||||
# 最下面那行放在 y=341:圆屏在 y=375 处正好收成一点,所以底边必须留在 375 以上。
|
||||
WARN_LABELS = [
|
||||
dict(kind='label', x=60, y=95, w=360, h=35,
|
||||
text='替换压力检测动画之前,', size=25, weight=S500, color=GRAY,
|
||||
align='left', vtop=True),
|
||||
dict(kind='label', x=60, y=129, w=360, h=35,
|
||||
text='请先确认自己的系统版本号为 ', size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
dict(kind='label', x=304, y=129, w=120, h=35, text='3.112.035',
|
||||
size=25, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
dict(kind='label', x=422, y=129, w=40, h=35, text='!',
|
||||
size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
# 型号拆成两行:y=195 处弦半宽 195,y=228 处 173 —— 367px 的长行只能拆
|
||||
dict(kind='label', x=64, y=195, w=360, h=35, text='请确认自己的手表型号为',
|
||||
size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
dict(kind='label', x=64, y=229, w=360, h=35, text='Xiaomi Watch S5',
|
||||
size=25, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
dict(kind='label', x=64, y=263, w=360, h=35, text='eSIM 46mm(p62lte)',
|
||||
size=25, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
dict(kind='label', x=60, y=341, w=360, h=35, text='搞机有风险,变砖后果自负。',
|
||||
size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
]
|
||||
PAGE_CONFIRM = [clock(), back_arrow(167), title('开始替换', 188, 124)] + WARN_LABELS + big_button(376, '确认替换')
|
||||
|
||||
LOG_MID = ('> 开始替换\n'
|
||||
'> health 119 条\n'
|
||||
'> 写入 60/119 成 60 失 0\n'
|
||||
'> 替换完毕,请重启手表。\n'
|
||||
'> 若发现问题,请回滚修改。')
|
||||
PAGE_RUNNING = [clock(), title('正在替换', 178, 124),
|
||||
dict(kind='label', x=67, y=95, w=348, h=280, text=LOG_MID, size=24,
|
||||
weight=S500, color=WHITE, align='left', vtop=True),
|
||||
*big_button(376, '重启手表')]
|
||||
|
||||
PAGE_ABOUT = [
|
||||
clock(), back_arrow(198), title('关于', 220, 62),
|
||||
dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31),
|
||||
dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')),
|
||||
dict(kind='label', x=181, y=200, w=120, h=40, text='S5e Pt.2',
|
||||
size=29, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
dict(kind='label', x=222, y=237, w=49, h=28, text='1.0.0',
|
||||
size=21, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
*item(300, 'ic_qq', '732681995'),
|
||||
]
|
||||
|
||||
PAGES = {'home': PAGE_HOME, 'help': PAGE_HELP, 'help_ok': PAGE_HELP_OK, 'help_err': PAGE_HELP_ERR,
|
||||
'confirm': PAGE_CONFIRM, 'running': PAGE_RUNNING, 'about': PAGE_ABOUT}
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""按「角色」切负载:replace(只负责替换)/ restore(只负责还原)。
|
||||
|
||||
为什么拆成两张盘
|
||||
----------------
|
||||
「负载 + 回滚」合在一张盘 = 两份 15.5 MB ≈ 31 MB,超过 .face 的体积上限。
|
||||
拆开之后每张盘只装一份负载(≈15.5 MB)。
|
||||
|
||||
拆盘后版本校验怎么办
|
||||
--------------------
|
||||
每张盘再带一张**指纹表**:每条记录存「另一侧字节的前 64 B」。
|
||||
* replace 盘:pay = q63 的帧(新字节);fp = **原厂字节**前 64 B
|
||||
* restore 盘:pay = 原厂字节; fp = **新字节**前 64 B
|
||||
于是「写入前版本校验」和「写入后回读比对」两道保险都还在,代价只有 119*64 ≈ 7.4 KB。
|
||||
|
||||
关键约定(S4e 那版踩过的坑)
|
||||
----------------------------
|
||||
Lua 表里存的是**分区键名**('health'),运行期才由 `DEVS[键]` 拼成 `/dev/health`。
|
||||
若把 '/dev/health' 直接存进表里,拼出来就是 `if=nil` 或 `if=/dev//dev/health`。
|
||||
|
||||
产出
|
||||
----
|
||||
payload/ NNN_<name>.bin + payload.lua (replace 盘的负载)
|
||||
payload_restore/ NNN_<name>.bin + payload.lua (restore 盘的负载)
|
||||
blob_replace/ payload.bin(魔数 + 顺序拼接)+ fp.bin + offsets.json
|
||||
blob_restore/ 同上
|
||||
payload.json 机器可读清单
|
||||
"""
|
||||
import os, sys, json, hashlib, struct
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from paths import PROJ, WORK, TARGET_IMAGE
|
||||
|
||||
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
|
||||
assert ROLE in ('replace', 'restore'), ROLE
|
||||
PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin')
|
||||
OUT = PROJ
|
||||
DEV = 'health' # ← Lua 表里的键名(不是 '/dev/health')
|
||||
FP_LEN = 64
|
||||
|
||||
recs_in = json.load(open(os.path.join(WORK, 'anim', 'manifest.json'), encoding='utf-8'))
|
||||
stock = open(TARGET_IMAGE, 'rb').read()
|
||||
patched = open(PATCHED, 'rb').read()
|
||||
assert len(stock) == len(patched), '改后镜像长度变了 —— 等长覆盖的前提被破坏'
|
||||
assert stock[:8] == b'-rom1fs-'
|
||||
|
||||
SRC = patched if ROLE == 'replace' else stock # 要写进设备的字节
|
||||
OTHER = stock if ROLE == 'replace' else patched # 另一侧字节(做指纹)
|
||||
FPDIR = os.path.join(OUT, 'payload' if ROLE == 'replace' else 'payload_restore')
|
||||
BLOBDIR = os.path.join(OUT, 'blob_' + ROLE)
|
||||
|
||||
records = []
|
||||
for r in recs_in['records']:
|
||||
off, ln = r['offset'], r['length']
|
||||
records.append(dict(name=r['name'], offset=off, length=ln, data=SRC[off:off + ln],
|
||||
changed=(stock[off:off + ln] != patched[off:off + ln]),
|
||||
fp=OTHER[off:off + FP_LEN]))
|
||||
# replace 盘跳过「本来就没变」的记录(省体积);restore 盘一条都不能少
|
||||
emitted = [r for r in records if (r['changed'] or ROLE == 'restore')]
|
||||
dropped = [r for r in records if r not in emitted]
|
||||
for r in emitted:
|
||||
assert len(r['data']) == r['length'], r['name']
|
||||
assert len(r['fp']) == FP_LEN, r['name']
|
||||
|
||||
|
||||
def write_pack(d):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for f in os.listdir(d):
|
||||
os.remove(os.path.join(d, f))
|
||||
rows = []
|
||||
for i, r in enumerate(emitted):
|
||||
fn = '%03d_%s.bin' % (i, r['name'])
|
||||
open(os.path.join(d, fn), 'wb').write(r['data'])
|
||||
assert r['offset'] % 4 == 0, r['name']
|
||||
rows.append(dict(name=r['name'], file=fn, offset=r['offset'], length=r['length']))
|
||||
with open(os.path.join(d, 'payload.lua'), 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('-- generated by tools/s5e_payload.py (%s) -- do not hand-edit\n' % ROLE)
|
||||
f.write('-- dd: -if <this dir>/<file> -of /dev/%s bs=4 seek=offset/4 count=length/4\n' % DEV)
|
||||
f.write('return {\n')
|
||||
for r in rows:
|
||||
f.write(' { name = "%s", file = "%s", offset = %d, length = %d },\n'
|
||||
% (r['name'], r['file'], r['offset'], r['length']))
|
||||
f.write('}\n')
|
||||
return rows
|
||||
|
||||
|
||||
lua_rows = write_pack(FPDIR)
|
||||
|
||||
# 魔数必须全局唯一,且**长度不要手写常量**(S4e/S5e 图标包都因为手写成 16 导致偏移差 1)
|
||||
MAGIC_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c'
|
||||
MAGIC_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3'
|
||||
os.makedirs(BLOBDIR, exist_ok=True)
|
||||
for f in os.listdir(BLOBDIR):
|
||||
os.remove(os.path.join(BLOBDIR, f))
|
||||
pay_blob = MAGIC_PAY + b''.join(r['data'] for r in emitted)
|
||||
fp_blob = MAGIC_FP + b''.join(r['fp'] for r in emitted)
|
||||
open(os.path.join(BLOBDIR, 'payload.bin'), 'wb').write(pay_blob)
|
||||
open(os.path.join(BLOBDIR, 'fp.bin'), 'wb').write(fp_blob)
|
||||
rel, off, fpoff = [], 0, 0
|
||||
for r in emitted:
|
||||
# 每条都存独立 sha256:.face 构建时用它校验"确实原样连续存放",
|
||||
# 而不是拿 .face 跟自己比(那种检查恒真、抓不到 bug)
|
||||
rel.append(dict(name=r['name'], rel=off, fprel=fpoff, target=r['offset'],
|
||||
length=r['length'], fplen=len(r['fp']),
|
||||
pay_sha=hashlib.sha256(pay_blob[len(MAGIC_PAY) + off:
|
||||
len(MAGIC_PAY) + off + r['length']]).hexdigest(),
|
||||
fp_sha=hashlib.sha256(fp_blob[len(MAGIC_FP) + fpoff:
|
||||
len(MAGIC_FP) + fpoff + len(r['fp'])]).hexdigest()))
|
||||
off += r['length']
|
||||
fpoff += len(r['fp'])
|
||||
assert fpoff == len(fp_blob) - len(MAGIC_FP), (fpoff, len(fp_blob))
|
||||
assert off == len(pay_blob) - len(MAGIC_PAY), (off, len(pay_blob))
|
||||
json.dump(dict(role=ROLE, device=DEV, magic_len=len(MAGIC_PAY), magic_fp_len=len(MAGIC_FP),
|
||||
fp_len=FP_LEN, records=rel, pay_bytes=len(pay_blob), fp_bytes=len(fp_blob),
|
||||
dropped_identical=[r['name'] for r in dropped]),
|
||||
open(os.path.join(BLOBDIR, 'offsets.json'), 'w', encoding='utf-8'), indent=1)
|
||||
print('blob_%s/ 单条目: payload.bin %d B + fp.bin %d B(%d 条记录,跳过 %d 条未变)'
|
||||
% (ROLE, len(pay_blob), len(fp_blob), len(rel), len(dropped)))
|
||||
|
||||
json.dump(dict(device='Xiaomi Watch S5 eSIM 46mm (p62lte)', target='/dev/' + DEV, dev_key=DEV,
|
||||
partition='vela_health.bin', role=ROLE, kind='pressure-measure-anim',
|
||||
dd='dd if=<dir>/<file> of=/dev/%s bs=4 seek=<offset/4> count=<length/4>' % DEV,
|
||||
block_size=4, records=lua_rows,
|
||||
dropped_identical=[r['name'] for r in dropped]),
|
||||
open(os.path.join(OUT, 'payload.json'), 'w', encoding='utf-8'), indent=1,
|
||||
ensure_ascii=False)
|
||||
total = sum(r['length'] for r in lua_rows)
|
||||
print('role=%s: emitted %d records, %.2f MB; dropped %d'
|
||||
% (ROLE, len(lua_rows), total / 1048576, len(dropped)))
|
||||
@@ -0,0 +1,196 @@
|
||||
"""按 480x480 页面规格渲染预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。
|
||||
|
||||
支持 kind: rect / circle / image / label
|
||||
label 支持 align / vtop / vcenter / lh,以及 LVGL 的 recolor 写法 "#RRGGBB 文字#"
|
||||
"""
|
||||
import os, re, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from paths import PROJ, INPUTS, FONTS, WORK
|
||||
ROOT = PROJ
|
||||
FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf',
|
||||
'regular': 'MiSans-Regular.ttf'}
|
||||
W = H = 480
|
||||
_fc = {}
|
||||
|
||||
|
||||
def _font_from_firmware(name):
|
||||
"""从 inputs/target/vela_font.bin 里现取 MiSans(固件里就自带这几个 ttf)。"""
|
||||
p = os.path.join(INPUTS, 'target', 'vela_font.bin')
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
try:
|
||||
from romfs import Romfs
|
||||
r = Romfs(p)
|
||||
except Exception:
|
||||
return None
|
||||
for path, ino in r.entries():
|
||||
if path == name:
|
||||
d = os.path.join(WORK, 'fonts')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
out = os.path.join(d, name)
|
||||
open(out, 'wb').write(r.read(ino))
|
||||
return out
|
||||
return None
|
||||
|
||||
|
||||
def font(weight, size):
|
||||
k = (weight, size)
|
||||
if k in _fc:
|
||||
return _fc[k]
|
||||
name = FONT_FILES.get(weight, FONT_FILES['medium'])
|
||||
p = os.path.join(FONTS, name)
|
||||
if not os.path.exists(p):
|
||||
p = _font_from_firmware(name) or p
|
||||
if not os.path.exists(p):
|
||||
for alt in (r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\msyhbd.ttc',
|
||||
r'C:\Windows\Fonts\arial.ttf'):
|
||||
if os.path.exists(alt):
|
||||
p = alt
|
||||
print(' [warn] %s 缺失,预览改用 %s(只影响预览缩略图)'
|
||||
% (name, os.path.basename(alt)))
|
||||
break
|
||||
_fc[k] = ImageFont.truetype(p, size) if os.path.exists(p) else ImageFont.load_default()
|
||||
return _fc[k]
|
||||
|
||||
|
||||
def _rgb(c):
|
||||
return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def split_recolor(text, base):
|
||||
runs, buf, cur, incol = [], '', base, False
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i] == '#':
|
||||
if re.match(r'[0-9a-fA-F]{6}', text[i + 1:i + 7]):
|
||||
runs.append((cur, buf)); buf = ''
|
||||
cur = text[i + 1:i + 7].lower(); incol = True
|
||||
i += 7
|
||||
continue
|
||||
if incol:
|
||||
runs.append((cur, buf)); buf = ''
|
||||
cur = base; incol = False
|
||||
i += 1
|
||||
continue
|
||||
buf += text[i]; i += 1
|
||||
runs.append((cur, buf))
|
||||
return runs
|
||||
|
||||
|
||||
def wrap_runs(d, runs, f, maxw):
|
||||
lines = [[]]
|
||||
for color, chunk in runs:
|
||||
for k, seg in enumerate(chunk.split('\n')):
|
||||
if k:
|
||||
lines.append([])
|
||||
if not seg:
|
||||
continue
|
||||
cur = ''
|
||||
for ch in seg:
|
||||
if d.textlength(cur + ch, font=f) > maxw and cur:
|
||||
lines[-1].append((color, cur))
|
||||
lines.append([])
|
||||
cur = ch
|
||||
else:
|
||||
cur += ch
|
||||
if cur:
|
||||
lines[-1].append((color, cur))
|
||||
return lines
|
||||
|
||||
|
||||
OVERFLOW = [] # (文本前 14 字, lh, 行数, 块高, 框高)
|
||||
|
||||
|
||||
def draw_label(img, d, it):
|
||||
f = font(it.get('weight', 'medium'), it['size'])
|
||||
runs = split_recolor(it['text'], it.get('color', 'ffffff')) \
|
||||
if it.get('recolor') else [(it.get('color', 'ffffff'), it['text'])]
|
||||
asc, desc = f.getmetrics()
|
||||
natural = asc + desc
|
||||
lh = max(it.get('lh', 0), natural)
|
||||
lines = wrap_runs(d, runs, f, it['w'])
|
||||
total = lh * len(lines)
|
||||
if total > it['h'] + 0.5:
|
||||
OVERFLOW.append((it['text'][:14].replace('\n', '/'), round(it.get('lh', 0), 2),
|
||||
len(lines), round(total, 1), it['h'], round(natural, 2)))
|
||||
if it.get('vcenter'):
|
||||
y = it['y'] + (it['h'] - total) / 2
|
||||
elif it.get('vtop'):
|
||||
y = it['y']
|
||||
else:
|
||||
y = it['y'] + (it['h'] - total) / 2
|
||||
for ln in lines:
|
||||
wsum = sum(d.textlength(t, font=f) for _, t in ln)
|
||||
if it.get('align') == 'center':
|
||||
x = it['x'] + (it['w'] - wsum) / 2
|
||||
elif it.get('align') == 'right':
|
||||
x = it['x'] + it['w'] - wsum
|
||||
else:
|
||||
x = it['x']
|
||||
for color, t in ln:
|
||||
d.text((x, y), t, font=f, fill=_rgb(color) + (255,))
|
||||
x += d.textlength(t, font=f)
|
||||
y += lh
|
||||
|
||||
|
||||
def render_raw(items):
|
||||
"""画成 RGBA(不遮罩、不铺黑底)—— 切边检查就用这个。"""
|
||||
img = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
for it in items:
|
||||
k = it.get('kind')
|
||||
if k == 'rect':
|
||||
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
|
||||
r = it.get('radius', 0)
|
||||
if r:
|
||||
d.rounded_rectangle(box, radius=r, fill=_rgb(it['bg']) + (255,))
|
||||
else:
|
||||
d.rectangle(box, fill=_rgb(it['bg']) + (255,))
|
||||
elif k == 'circle':
|
||||
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
|
||||
d.ellipse(box, fill=_rgb(it['bg']) + (255,))
|
||||
elif k == 'image':
|
||||
p = it['src']
|
||||
if os.path.exists(p):
|
||||
im = Image.open(p).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS)
|
||||
img.alpha_composite(im, (it['x'], it['y']))
|
||||
elif k == 'label':
|
||||
draw_label(img, d, it)
|
||||
return img
|
||||
|
||||
|
||||
def render(items, out, mask=True, ring=False):
|
||||
img = render_raw(items)
|
||||
if mask:
|
||||
m = Image.new('L', (W, H), 0)
|
||||
ImageDraw.Draw(m).ellipse([0, 0, W - 1, H - 1], fill=255)
|
||||
img.putalpha(m)
|
||||
base = Image.new('RGB', (W, H), (0, 0, 0))
|
||||
base.paste(img, (0, 0), img)
|
||||
if ring:
|
||||
ImageDraw.Draw(base).ellipse([0, 0, W - 1, H - 1], outline=(80, 80, 90))
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
base.save(out)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from s5e_pages import PAGES
|
||||
outs = []
|
||||
for name, items in PAGES.items():
|
||||
p = os.path.join(ROOT, 'docs', 'ui_%s.png' % name)
|
||||
render(items, p)
|
||||
outs.append(p)
|
||||
print('wrote', p)
|
||||
cols = 3
|
||||
rows = (len(outs) + cols - 1) // cols
|
||||
sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 12) + 12), (28, 28, 32))
|
||||
for i, p in enumerate(outs):
|
||||
sheet.paste(Image.open(p), (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12)))
|
||||
sp = os.path.join(ROOT, 'docs', 'ui_all.png')
|
||||
sheet.save(sp)
|
||||
print('wrote', sp, sheet.size)
|
||||
if OVERFLOW:
|
||||
print('!! 文本溢出 %d 处:%s' % (len(OVERFLOW), OVERFLOW))
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UI 素材 PNG -> LVGL v9 .bin(480x480 表盘用)+ 80px 圆形 logo。
|
||||
|
||||
格式(真机验证过):12 B 头 magic=0x19|cf|flags|w|h|stride|rsv + BGRA 像素。
|
||||
编码器复用 iconpack.encode(与图标包同一套)。
|
||||
"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import iconpack as ip
|
||||
from PIL import Image, ImageDraw
|
||||
from paths import PROJ, DOCS, ASSETS
|
||||
|
||||
OUT = os.path.join(PROJ, 'ui_images')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
SPEC = [('back', 'backarrow.png', 18, 29),
|
||||
('ic_help', 'MdiLightbulbOutline.png', 31, 31),
|
||||
('ic_replace', 'MdiBandage.png', 31, 31),
|
||||
('ic_about', 'MdiInformationOutline.png', 31, 31),
|
||||
('ic_check', 'MdiClipboardTextSearchOutline.png', 31, 31),
|
||||
('ic_rollback', 'MdiReplyAllOutline.png', 31, 31),
|
||||
('ic_qq', 'qq.png', 31, 31)]
|
||||
for name, png, w, h in SPEC:
|
||||
p = os.path.join(ASSETS, png)
|
||||
if not os.path.exists(p):
|
||||
print(' missing', png)
|
||||
continue
|
||||
img = Image.open(p).convert('RGBA')
|
||||
if img.size != (w, h):
|
||||
img = img.resize((w, h), Image.LANCZOS)
|
||||
blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0)
|
||||
open(os.path.join(OUT, name + '.bin'), 'wb').write(blob)
|
||||
print(' %-12s <- %-34s %dx%d %d bytes' % (name, png, w, h, len(blob)))
|
||||
|
||||
L = 80
|
||||
img = Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA').resize((L, L), Image.LANCZOS)
|
||||
mask = Image.new('L', (L, L), 0)
|
||||
ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255)
|
||||
img.putalpha(mask)
|
||||
open(os.path.join(OUT, 'logo.bin'), 'wb').write(ip.encode(img, ip.CF_ARGB8888, L, L, 0))
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
img.save(os.path.join(DOCS, 'logo_preview_480.png'))
|
||||
print(' %-12s <- cover.png(circular) %dx%d' % ('logo', L, L))
|
||||
print('->', OUT)
|
||||
@@ -0,0 +1,221 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""独立校验交付的两张 .face —— 只读交付物 + 固件镜像,不信任任何中间产物。
|
||||
|
||||
role=replace -> dist/S5ePAnim.face pay 应等于**改后镜像**的字节,rb 应等于**原厂**前 64 B
|
||||
role=restore -> dist/S5ePAnimRestore.face pay 应等于**原厂**字节, rb 应等于**改后**前 64 B
|
||||
|
||||
查的东西(缺一不可):
|
||||
① .face 头:magic / b4 / b5 / id / 预览块尺寸 326x326
|
||||
② 从 .face 里**反解**加载的 Lua 源码:ROLE 正确、占位符已回填、
|
||||
分区键是 'health'(不是 '/dev/health',那样运行时会拼成 if=nil)
|
||||
③ pay / rb 表逐条:目标偏移 == 原厂槽位 data_off、长度 == 原槽位长度(等长覆盖的底线)
|
||||
④ 每条 pay 长度 == 原槽位长度,且能按 LVGL RLE 解出**正好 usize 字节**、解成 464x464
|
||||
⑤ 每条 pay 字节 == 另一侧镜像对应位置;每条 rb 指纹 == 另一侧前 64 B
|
||||
⑥ 改后镜像的 inode 表 / superblock / 分区长度与原厂**逐位一致**
|
||||
⑦ 负载在 .face 里原样连续存放(偏移 == 文件基址 + 魔数长度 + 相对偏移 的前提)
|
||||
"""
|
||||
import os, re, sys, json, struct, hashlib
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from paths import (PROJ, INPUTS, WORK, DIST, TARGET_IMAGE, DIRP, FACE_W, FACE_H,
|
||||
WF_ID, PREVIEW, SCREEN)
|
||||
from romfs import Romfs
|
||||
import lvgl_bin as L
|
||||
|
||||
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
|
||||
assert ROLE in ('replace', 'restore'), ROLE
|
||||
NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore'
|
||||
FACE = os.path.join(DIST, NAME + '.face')
|
||||
PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin')
|
||||
FP_LEN = 64
|
||||
ok = fail = 0
|
||||
|
||||
|
||||
def ck(cond, msg):
|
||||
global ok, fail
|
||||
if cond:
|
||||
ok += 1
|
||||
print(' OK ' + msg)
|
||||
else:
|
||||
fail += 1
|
||||
print(' FAIL ' + msg)
|
||||
|
||||
|
||||
print('=' * 78)
|
||||
print('%s(role=%s) %s' % (NAME, ROLE, FACE))
|
||||
print('=' * 78)
|
||||
if not os.path.exists(FACE):
|
||||
raise SystemExit('交付物不存在:%s' % FACE)
|
||||
face = open(FACE, 'rb').read()
|
||||
stock_b = open(TARGET_IMAGE, 'rb').read()
|
||||
patch_b = open(PATCHED, 'rb').read()
|
||||
stock = Romfs(TARGET_IMAGE)
|
||||
patched = Romfs(PATCHED)
|
||||
|
||||
# ---------------------------------------------------------------- ① 头
|
||||
print('\n[1] .face 头')
|
||||
ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12')
|
||||
ck(face[4] in (0, 1, 2), 'b4 = %d' % face[4])
|
||||
ck(face[5] == 0, 'b5 = %d(固件自带表盘 b5 也是 0)' % face[5])
|
||||
ck(face[40:50] == WF_ID.encode() + b'\x00', 'id = %r(期望 %r)' % (face[40:50], WF_ID))
|
||||
a32, = struct.unpack('<I', face[32:36])
|
||||
pal, pw, ph, psz = struct.unpack('<IHHI', face[a32:a32 + 12])
|
||||
ck((pw, ph) == (PREVIEW, PREVIEW), 'preview %dx%d(Compiler.exe 对 462 设备强制 %dx%d)'
|
||||
% (pw, ph, PREVIEW, PREVIEW))
|
||||
ck(a32 + 12 + psz <= len(face) + 1, 'preview 数据 %d B @0x%x(文件尾 0x%x)' % (psz, a32 + 12, len(face)))
|
||||
print(' 文件 %d B sha256 %s' % (len(face), hashlib.sha256(face).hexdigest()))
|
||||
|
||||
# ---------------------------------------------------------------- ② Lua 源码
|
||||
print('\n[2] 从 .face 里反解加载的 Lua')
|
||||
_end = face.find(b'return ui\n')
|
||||
ck(_end > 0, '找到 Lua 结尾 "return ui\\n"')
|
||||
_start = face.rfind(b'--[[', 0, _end)
|
||||
lua = face[_start:_end + len(b'return ui\n')]
|
||||
ck(lua.startswith(b'--[[--') and b'local UIIMG = {' in lua, 'Lua 提取成功(%d B)' % len(lua))
|
||||
txt = lua.decode('utf-8')
|
||||
ck(face.count(lua) == 1, 'Lua 原样出现 1 次(偏移 0x%x)' % face.find(lua))
|
||||
c = re.search(r"local ROLE = '(\w+)'", txt)
|
||||
ck(c is not None and c.group(1) == ROLE, "Lua 里的 ROLE == '%s'" % ROLE)
|
||||
ck('__UIIMG__' not in txt and '__PAY__' not in txt and '__RB__' not in txt
|
||||
and '__ROLE__' not in txt, '占位符已全部回填')
|
||||
ck(re.search(r'local W, H = %d, %d' % (SCREEN, SCREEN), txt) is not None,
|
||||
'界面尺寸 W,H = %d,%d(480x480 圆屏)' % (SCREEN, SCREEN))
|
||||
# ★ S4e 那版踩过的坑:Lua 表里存 '/dev/app',运行时又拼一次 -> if=nil
|
||||
mdev = re.search(r'local DEVS = \{([^}]*)\}', txt)
|
||||
ck(mdev is not None and '/dev/health' in mdev.group(1),
|
||||
'DEVS 里有 health -> /dev/health:%s' % (mdev.group(1).strip() if mdev else None))
|
||||
|
||||
# ---------------------------------------------------------------- 偏移(自己找,不信 Lua)
|
||||
print('\n[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)')
|
||||
ML_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c'
|
||||
ML_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3'
|
||||
offs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'), encoding='utf-8'))
|
||||
pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read()
|
||||
fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read()
|
||||
ck(face.count(ML_PAY) == 1, 'payload 魔数(%d B)在 .face 里唯一' % len(ML_PAY))
|
||||
ck(face.count(ML_FP) == 1, 'fp 魔数(%d B)在 .face 里唯一' % len(ML_FP))
|
||||
pay_base = face.find(ML_PAY)
|
||||
fp_base = face.find(ML_FP)
|
||||
ck(face.find(pay_blob) == pay_base, 'payload.bin 原样连续存放在 0x%x' % pay_base)
|
||||
ck(face.find(fp_blob) == fp_base, 'fp.bin 原样连续存放在 0x%x' % fp_base)
|
||||
recs = offs['records']
|
||||
ck(len(recs) == 119, '负载记录 %d 条(期望 119)' % len(recs))
|
||||
# 整个负载文件必须**原样连续**放在 base 处 —— 这是"偏移 == base + 魔数 + rel"成立的前提
|
||||
ck(face[pay_base:pay_base + len(pay_blob)] == pay_blob,
|
||||
'payload.bin %d B 在 .face 里逐字节连续' % len(pay_blob))
|
||||
ck(face[fp_base:fp_base + len(fp_blob)] == fp_blob,
|
||||
'fp.bin %d B 在 .face 里逐字节连续' % len(fp_blob))
|
||||
|
||||
# Lua 表里的偏移必须和独立量出来的一致
|
||||
pay = [tuple(int(x) for x in r) for r in
|
||||
re.findall(r'\{(\d+),(\d+),(\d+)\}', re.search(r'pay = \{(.*?)\n \},', txt, re.S).group(1))]
|
||||
rb = [tuple(int(x) for x in r) for r in
|
||||
re.findall(r'\{(\d+),(\d+),(\d+)\}', re.search(r'rb = \{(.*?)\n \},', txt, re.S).group(1))]
|
||||
ck(len(pay) == len(recs) and len(rb) == len(recs),
|
||||
'Lua 表 pay %d 条 / rb %d 条' % (len(pay), len(rb)))
|
||||
bad_off = 0
|
||||
for i, r in enumerate(recs):
|
||||
if pay[i] != (pay_base + len(ML_PAY) + r['rel'], r['target'], r['length']):
|
||||
bad_off += 1
|
||||
if rb[i] != (fp_base + len(ML_FP) + r['fprel'], r['target'], r['fplen']):
|
||||
bad_off += 1
|
||||
ck(bad_off == 0, 'Lua 表里的 %d 条偏移 == 文件基址 + 魔数长度 + 相对偏移' % len(recs))
|
||||
|
||||
# ---------------------------------------------------------------- 原厂槽位
|
||||
slots = {}
|
||||
for p, i in stock.entries():
|
||||
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0:
|
||||
continue
|
||||
slots[i.data_off] = (p, i.size, i)
|
||||
ck(len(slots) == 119, '原厂 %s 下 %d 个槽位' % (DIRP, len(slots)))
|
||||
usizes = {struct.unpack('<III', stock.read(slots[o][2])[12:24])[2] for o in slots}
|
||||
ck(usizes == {1024 + FACE_W * FACE_H + 1},
|
||||
'原厂 usize 唯一且 == 1024 + %d*%d + 1 = %d' % (FACE_W, FACE_H, 1024 + FACE_W * FACE_H + 1))
|
||||
|
||||
# ---------------------------------------------------------------- ④⑤ 逐条
|
||||
print('\n[4] 逐条负载')
|
||||
EXPECT_PAY = patch_b if ROLE == 'replace' else stock_b
|
||||
EXPECT_FP = stock_b if ROLE == 'replace' else patch_b
|
||||
bad_len = bad_dec = bad_bytes = bad_shape = 0
|
||||
for i, r in enumerate(recs):
|
||||
o, t, ln = pay[i]
|
||||
blob = face[o:o + ln]
|
||||
if t not in slots or slots[t][1] != ln or ln != r['length']:
|
||||
bad_len += 1
|
||||
continue
|
||||
if EXPECT_PAY[t:t + ln] != blob:
|
||||
bad_bytes += 1
|
||||
h = L.parse_header(blob)
|
||||
if h is None or not (h['flags'] & L.FLAG_COMPRESSED) or (h['w'], h['h']) != (FACE_W, FACE_H):
|
||||
bad_dec += 1
|
||||
continue
|
||||
_m, _cs, _us = struct.unpack('<III', blob[12:24])
|
||||
if len(L.rle_decompress(blob[24:24 + _cs], 1, None)) != _us or _us not in usizes:
|
||||
bad_dec += 1
|
||||
continue
|
||||
try:
|
||||
img = L.decode_lvgl_bin(blob)[1]
|
||||
if img is None or img.size != (FACE_W, FACE_H):
|
||||
bad_dec += 1
|
||||
except Exception:
|
||||
bad_dec += 1
|
||||
ck(bad_len == 0, '每条负载长度 == 原槽位长度(%d 条)' % len(recs))
|
||||
ck(bad_bytes == 0, '每条负载字节 == %s镜像对应位置'
|
||||
% ('改后' if ROLE == 'replace' else '原厂'))
|
||||
ck(bad_dec == 0, '每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 %dx%d' % (FACE_W, FACE_H))
|
||||
|
||||
print('\n[5] 版本校验指纹')
|
||||
bad_fp = 0
|
||||
for i, r in enumerate(recs):
|
||||
o, t, ln = rb[i]
|
||||
if ln != FP_LEN or t not in slots or face[o:o + ln] != EXPECT_FP[t:t + ln]:
|
||||
bad_fp += 1
|
||||
ck(bad_fp == 0, '%d 条指纹都是 64 B 且 == %s镜像前 64 B'
|
||||
% (len(recs), '原厂' if ROLE == 'replace' else '改后'))
|
||||
# 指纹必须不是"恒真":至少要有记录真的不同
|
||||
diff_fp = sum(1 for i, r in enumerate(recs) if stock_b[pay[i][1]:pay[i][1] + FP_LEN] !=
|
||||
patch_b[pay[i][1]:pay[i][1] + FP_LEN])
|
||||
ck(diff_fp > 100, '%d/%d 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)'
|
||||
% (diff_fp, len(recs)))
|
||||
|
||||
# ---------------------------------------------------------------- ⑥ 镜像完整性
|
||||
print('\n[6] 改后镜像的完整性')
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()]
|
||||
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in patched.entries()]
|
||||
ck(a == b, 'inode 表(路径/偏移/next/spec/size/cksum)%d 条逐位不变' % len(a))
|
||||
ck(stock.sb_size == patched.sb_size and stock.sb_cksum == patched.sb_cksum,
|
||||
'superblock 一致(size %d / cksum %08x)' % (patched.sb_size, patched.sb_cksum))
|
||||
ck(stock.volume == patched.volume == 'health', '卷名 health 不变')
|
||||
ck(len(stock_b) == len(patch_b), '分区长度不变(%d B)' % len(patch_b))
|
||||
chg = [i for i in range(len(stock_b)) if stock_b[i] != patch_b[i]]
|
||||
last_slot_end = max(o + slots[o][1] for o in slots)
|
||||
ck(chg and chg[0] >= min(slots) and chg[-1] < last_slot_end,
|
||||
'改动只落在负载槽位区间内(首差 0x%x,末差 0x%x,槽位区间 0x%x..0x%x)'
|
||||
% (chg[0], chg[-1], min(slots), last_slot_end))
|
||||
# 每个槽位都必须真的被改过(不是"看起来写了、其实字节没变")
|
||||
unchanged = [slots[o][0] for o in slots
|
||||
if stock_b[o:o + slots[o][1]] == patch_b[o:o + slots[o][1]]]
|
||||
ck(not unchanged, '119 个槽位全部都真的写入了新字节(未变的槽位:%s)' % (unchanged or '无'))
|
||||
|
||||
# ---------------------------------------------------------------- ⑦ UI 图
|
||||
print('\n[7] UI 图片')
|
||||
uipairs = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt)
|
||||
ck(len(uipairs) == 8, 'UIIMG 条目 %d 条(期望 8)' % len(uipairs))
|
||||
badui = 0
|
||||
for o, l, n in uipairs:
|
||||
blob = open(os.path.join(PROJ, 'ui_images', n + '.bin'), 'rb').read()
|
||||
if int(l) != len(blob) or face[int(o):int(o) + int(l)] != blob:
|
||||
badui += 1
|
||||
ck(badui == 0, '8 张界面素材都与 ui_images/*.bin 逐字节一致')
|
||||
|
||||
# ---------------------------------------------------------------- 体积
|
||||
print('\n[8] 体积')
|
||||
ck(len(face) < 20 * 1048576, '交付物 %.2f MB < 20 MB' % (len(face) / 1048576))
|
||||
print(' 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间')
|
||||
|
||||
print('\n' + '=' * 78)
|
||||
print('%s:%d 项通过,%d 项失败' % (NAME, ok, fail))
|
||||
if fail:
|
||||
sys.exit(1)
|
||||
print('全部通过 —— %s(%d B,sha256 %s)'
|
||||
% (os.path.basename(FACE), len(face), hashlib.sha256(face).hexdigest()[:16]))
|
||||
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""独立总校验 —— 只读两张交付物 + 固件镜像,**不读任何构建中间产物**。
|
||||
|
||||
与 tools/s5e_verify.py 的分工
|
||||
-----------------------------
|
||||
s5e_verify.py 会读 `blob_<role>/offsets.json` 来核对 Lua 表里的偏移。本脚本刻意
|
||||
**不读** offsets.json / .work/overlay / .work/build,改为:
|
||||
|
||||
* 自己从 `.face` 里反解 Lua(占位符、ROLE、pay/rb/UIIMG 三张表)
|
||||
* 自己从**固件镜像**里量出 119 个槽位(名字 / 偏移 / 长度)
|
||||
* 自己证明两张表「自洽 + 连续」:pay[0] 必须紧跟在魔数之后、pay[i+1] == pay[i]+len
|
||||
* 自己证明**第 k 条负载落在 Measuring{k}.bin 上**(名字对得上,不只是偏移对得上)
|
||||
* 逐条把负载解成像素:RLE 解出**恰好 usize**,且多出来的那 1 字节正好在像素区之后
|
||||
(这是"少 1 像素整帧不画"的那个坑)
|
||||
* 两张交付物**互相印证**(replace 的 pay 前 64 B == restore 的 rb,反之亦然)——
|
||||
这一条完全不碰 .work,也不碰固件镜像
|
||||
* 镜像级:inode 表 / superblock / 卷名 / 长度逐位不变;除 119 个槽位外,
|
||||
**其余每一个文件的每一个字节都逐字节不变**(不是只做区间统计)
|
||||
|
||||
用法:python tools/s5e_verify_indep.py
|
||||
"""
|
||||
import os, re, sys, hashlib, struct
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from paths import PROJ, DIST, TARGET_IMAGE, DIRP, FACE_W, FACE_H, WF_ID, PREVIEW, SCREEN, WORK
|
||||
from romfs import Romfs
|
||||
import lvgl_bin as L
|
||||
|
||||
MAGIC_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c'
|
||||
MAGIC_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3'
|
||||
FACES = {'replace': 'S5ePAnim.face', 'restore': 'S5ePAnimRestore.face'}
|
||||
PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin')
|
||||
UI_NAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback',
|
||||
'ic_qq', 'logo']
|
||||
ok = fail = 0
|
||||
|
||||
|
||||
def ck(cond, msg):
|
||||
global ok, fail
|
||||
if cond:
|
||||
ok += 1
|
||||
print(' OK ' + msg)
|
||||
else:
|
||||
fail += 1
|
||||
print(' FAIL ' + msg)
|
||||
|
||||
|
||||
def parse_face(path):
|
||||
"""把一张 .face 反解成:头 + Lua 文本 + 三张表 + 魔数位置。"""
|
||||
d = open(path, 'rb').read()
|
||||
end = d.find(b'return ui\n')
|
||||
start = d.rfind(b'--[[', 0, end)
|
||||
lua = d[start:end + len(b'return ui\n')]
|
||||
txt = lua.decode('utf-8')
|
||||
out = dict(data=d, lua=lua, txt=txt, lua_off=start)
|
||||
m = re.search(r"local ROLE = '(\w+)'", txt)
|
||||
out['role'] = m.group(1) if m else None
|
||||
for key, pat in (('pay', r'pay = \{(.*?)\n \},'), ('rb', r'rb = \{(.*?)\n \},')):
|
||||
blk = re.search(pat, txt, re.S)
|
||||
out[key] = ([tuple(int(x) for x in t)
|
||||
for t in re.findall(r'\{(\d+),(\d+),(\d+)\}', blk.group(1))]
|
||||
if blk else [])
|
||||
out['ui'] = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt)
|
||||
out['pay_base'] = d.find(MAGIC_PAY)
|
||||
out['fp_base'] = d.find(MAGIC_FP)
|
||||
out['pay_magic_n'] = d.count(MAGIC_PAY)
|
||||
out['fp_magic_n'] = d.count(MAGIC_FP)
|
||||
out['lua_n'] = d.count(lua)
|
||||
return out
|
||||
|
||||
|
||||
def slots_of(stock):
|
||||
"""固件镜像里的 119 个槽位:名字 -> (偏移, 长度),并给出名字顺序的列表。"""
|
||||
sl = {}
|
||||
for p, i in stock.entries():
|
||||
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0:
|
||||
continue
|
||||
sl[os.path.basename(p)] = (i.data_off, i.size)
|
||||
return sl
|
||||
|
||||
|
||||
def main():
|
||||
stock_b = open(TARGET_IMAGE, 'rb').read()
|
||||
patch_b = open(PATCHED, 'rb').read()
|
||||
stock = Romfs(TARGET_IMAGE)
|
||||
patched = Romfs(PATCHED)
|
||||
sl = slots_of(stock)
|
||||
exp_name = ['Measuring%d.bin' % k for k in range(1, len(sl) + 1)]
|
||||
print('=' * 78)
|
||||
print('独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build)')
|
||||
print(' 交付物 : dist/%s + dist/%s' % (FACES['replace'], FACES['restore']))
|
||||
print(' 固件 : inputs/target/vela_health.bin %d B(卷名 %s)'
|
||||
% (len(stock_b), stock.volume))
|
||||
print(' 改后 : .work/anim/vela_health_anim.bin %d B' % len(patch_b))
|
||||
print('=' * 78)
|
||||
ck(len(sl) == 119, '固件里 %s 下 %d 个槽位(期望 119)' % (DIRP, len(sl)))
|
||||
ck(sorted(sl.keys()) == sorted(exp_name), '槽位名字就是 Measuring1..119.bin')
|
||||
|
||||
faces = {}
|
||||
for role, fn in FACES.items():
|
||||
f = parse_face(os.path.join(DIST, fn))
|
||||
faces[role] = f
|
||||
other = patch_b if role == 'replace' else stock_b
|
||||
print('\n' + '-' * 78)
|
||||
print('[%s] %s %d B sha256 %s'
|
||||
% (role, fn, len(f['data']), hashlib.sha256(f['data']).hexdigest()))
|
||||
print('-' * 78)
|
||||
|
||||
# ---- ① 头 + Lua ----
|
||||
d = f['data']
|
||||
ck(d[0:4] == b'\x5a\xa5\x34\x12', 'magic')
|
||||
ck(d[5] == 0, 'b5 = %d(固件自带表盘是 0)' % d[5])
|
||||
ck(d[4] == 0, 'b4 = %d' % d[4])
|
||||
ck(d[40:50] == WF_ID.encode() + b'\x00', 'id = %r' % d[40:50])
|
||||
a32, = struct.unpack('<I', d[32:36])
|
||||
pal, pw, ph, psz = struct.unpack('<IHHI', d[a32:a32 + 12])
|
||||
ck((pw, ph) == (PREVIEW, PREVIEW), 'preview %dx%d' % (pw, ph))
|
||||
ck(f['lua_n'] == 1, 'Lua 在文件里只出现 1 次(偏移 0x%x)' % f['lua_off'])
|
||||
ck(f['role'] == role, "Lua 里的 ROLE == '%s'" % role)
|
||||
for ph_ in ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__'):
|
||||
ck(ph_ not in f['txt'], '占位符 %s 已回填' % ph_)
|
||||
ck(re.search(r'local W, H = %d, %d' % (SCREEN, SCREEN), f['txt']) is not None,
|
||||
'界面尺寸 W,H = %d,%d' % (SCREEN, SCREEN))
|
||||
m = re.search(r'local DEVS = \{([^}]*)\}', f['txt'])
|
||||
ck(m is not None and '/dev/health' in m.group(1),
|
||||
'DEVS 有 health -> /dev/health')
|
||||
ck(len(f['ui']) == 8, 'UIIMG %d 条(期望 8)' % len(f['ui']))
|
||||
|
||||
# ---- ② 表自洽 + 在文件里连续(完全不看 offsets.json)----
|
||||
pay, rb = f['pay'], f['rb']
|
||||
ck(len(pay) == 119 and len(rb) == 119, 'pay %d 条 / rb %d 条(期望 119/119)'
|
||||
% (len(pay), len(rb)))
|
||||
ck(f['pay_magic_n'] == 1 and f['fp_magic_n'] == 1,
|
||||
'两个魔数各命中 1 次(payload 0x%x / fp 0x%x)' % (f['pay_base'], f['fp_base']))
|
||||
cont = 0
|
||||
for i, (o, _t, ln) in enumerate(pay):
|
||||
if i == 0:
|
||||
if o != f['pay_base'] + len(MAGIC_PAY):
|
||||
cont += 1
|
||||
elif o != pay[i - 1][0] + pay[i - 1][2]:
|
||||
cont += 1
|
||||
for i, (o, _t, ln) in enumerate(rb):
|
||||
if i == 0:
|
||||
if o != f['fp_base'] + len(MAGIC_FP):
|
||||
cont += 1
|
||||
elif o != rb[i - 1][0] + rb[i - 1][2]:
|
||||
cont += 1
|
||||
ck(cont == 0, '两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(%d 处不连续)' % cont)
|
||||
ck(all(rb[i][2] == 64 for i in range(len(rb))), 'rb 每条都是 64 B')
|
||||
|
||||
# ---- ③ 第 k 条落在 Measuring{k}.bin 上 ----
|
||||
bad_name = bad_len = 0
|
||||
for i, (o, t, ln) in enumerate(pay):
|
||||
if t != sl[exp_name[i]][0]:
|
||||
bad_name += 1
|
||||
if ln != sl[exp_name[i]][1] or rb[i][1] != t:
|
||||
bad_len += 1
|
||||
ck(bad_name == 0, '第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(%d 处不符)' % bad_name)
|
||||
ck(bad_len == 0, '每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(%d 处不符)'
|
||||
% bad_len)
|
||||
ck(all(t % 512 == 0 for _o, t, _l in pay), '119 个目标偏移全部 512 对齐')
|
||||
odd = sum(1 for _o, _t, l in pay if l % 4 != 0)
|
||||
print(' (%d/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,'
|
||||
'否则退化成 bs=1)' % odd)
|
||||
|
||||
# ---- ④ 负载字节 == 对应镜像;⑤ 解码 ----
|
||||
bad_bytes = bad_dec = bad_hdr = 0
|
||||
big_e = 0
|
||||
for i, (o, t, ln) in enumerate(pay):
|
||||
blob = d[o:o + ln]
|
||||
if blob != other[t:t + ln]:
|
||||
bad_bytes += 1
|
||||
if blob == (stock_b if role == 'replace' else patch_b)[t:t + ln]:
|
||||
big_e += 1
|
||||
h = L.parse_header(blob)
|
||||
if (h is None or h['cf'] != 0x0a or not (h['flags'] & L.FLAG_COMPRESSED)
|
||||
or (h['w'], h['h']) != (FACE_W, FACE_H) or h['stride'] != FACE_W):
|
||||
bad_hdr += 1
|
||||
continue
|
||||
_m, cs, us = struct.unpack('<III', blob[12:24])
|
||||
try:
|
||||
raw = L.rle_decompress(blob[24:24 + cs], 1, None)
|
||||
except Exception:
|
||||
bad_dec += 1
|
||||
continue
|
||||
# ① 解出来必须**恰好** usize(少一字节设备端整帧不画)
|
||||
# ② usize = 1024 + w*h + 1 -> 像素区之后**正好** 1 字节尾巴(usize_tail)
|
||||
if (len(raw) != us or us != 1024 + FACE_W * FACE_H + 1
|
||||
or len(raw) - 1024 - FACE_W * FACE_H != 1):
|
||||
bad_dec += 1
|
||||
continue
|
||||
try:
|
||||
img = L.decode_lvgl_bin(blob)[1]
|
||||
if img is None or img.size != (FACE_W, FACE_H):
|
||||
bad_dec += 1
|
||||
except Exception:
|
||||
bad_dec += 1
|
||||
ck(bad_bytes == 0, '每条负载字节 == %s镜像对应位置(%d 处不符)'
|
||||
% ('改后' if role == 'replace' else '原厂', bad_bytes))
|
||||
ck(big_e == 0, '每条负载都真的与"另一侧"不同(%d 条与另一侧相同 —— 应当是 0)' % big_e)
|
||||
ck(bad_hdr == 0, '每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(%d 条不符)' % bad_hdr)
|
||||
ck(bad_dec == 0, '每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴'
|
||||
'(%d 条不符)' % bad_dec)
|
||||
|
||||
# ---- ⑥ 指纹有区分度 ----
|
||||
diff_fp = sum(1 for i in range(len(pay))
|
||||
if stock_b[pay[i][1]:pay[i][1] + 64] != patch_b[pay[i][1]:pay[i][1] + 64])
|
||||
ck(diff_fp == 119, '%d/119 条的"原厂前 64 B"与"改后前 64 B"确实不同' % diff_fp)
|
||||
ck(all(rb[i] == (rb[i][0], sl[exp_name[i]][0], 64) for i in range(len(rb))),
|
||||
'rb 的 target 也指向对应槽位')
|
||||
|
||||
# ---- ⑦ UI 素材 ----
|
||||
badui = 0
|
||||
for o, l, n in f['ui']:
|
||||
blob = open(os.path.join(PROJ, 'ui_images', n + '.bin'), 'rb').read()
|
||||
if int(l) != len(blob) or d[int(o):int(o) + int(l)] != blob:
|
||||
badui += 1
|
||||
ck(badui == 0, '8 张界面素材与 ui_images/*.bin 逐字节一致(%d 处不符)' % badui)
|
||||
|
||||
# ================================================================ 两张盘互证
|
||||
print('\n' + '=' * 78)
|
||||
print('[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像)')
|
||||
print('=' * 78)
|
||||
rp, rr = faces['replace'], faces['restore']
|
||||
ck(len(rp['data']) == len(rr['data']), '两张盘字节数相同(%d)' % len(rp['data']))
|
||||
ck(rp['data'][:50] == rr['data'][:50],
|
||||
'头 50 B(magic / b4 / b5 / id)逐字节相同')
|
||||
cross = 0
|
||||
for i in range(119):
|
||||
if rp['pay'][i][1] != rr['pay'][i][1]:
|
||||
cross += 1
|
||||
continue
|
||||
o1, t, l1 = rp['pay'][i]
|
||||
o2, _t2, l2 = rr['pay'][i]
|
||||
if rp['data'][o1:o1 + 64] != rr['data'][rr['rb'][i][0]:rr['rb'][i][0] + 64]:
|
||||
cross += 1
|
||||
if rr['data'][o2:o2 + 64] != rp['data'][rp['rb'][i][0]:rp['rb'][i][0] + 64]:
|
||||
cross += 1
|
||||
ck(cross == 0, 'replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == '
|
||||
'replace 的指纹(%d 处不符)' % cross)
|
||||
same_ui = all(rp['data'][int(o):int(o) + int(l)] == rr['data'][int(o):int(o) + int(l)]
|
||||
for o, l, _n in rp['ui'])
|
||||
ck(same_ui, '两张盘的 8 张界面素材逐字节相同(同一套界面)')
|
||||
ck(rp['pay_base'] == rr['pay_base'] and rp['fp_base'] == rr['fp_base'],
|
||||
'两张盘的资源基址相同(0x%x / 0x%x)' % (rp['pay_base'], rr['fp_base']))
|
||||
ndiff = sum(1 for i in range(119)
|
||||
if rp['data'][rp['pay'][i][0]:rp['pay'][i][0] + rp['pay'][i][2]] !=
|
||||
rr['data'][rr['pay'][i][0]:rr['pay'][i][0] + rr['pay'][i][2]])
|
||||
ck(ndiff == 119, '119/119 条负载在两张盘里都不一样(%d)' % ndiff)
|
||||
|
||||
# ================================================================ 镜像完整性
|
||||
print('\n' + '=' * 78)
|
||||
print('[镜像] 改后镜像 vs 原厂镜像')
|
||||
print('=' * 78)
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()]
|
||||
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in patched.entries()]
|
||||
ck(a == b, 'inode 表(路径/偏移/next/spec/size/cksum)%d 条逐位不变' % len(a))
|
||||
ck((stock.sb_size, stock.sb_cksum, stock.volume) ==
|
||||
(patched.sb_size, patched.sb_cksum, patched.volume),
|
||||
'superblock 与卷名不变(size %d / cksum %08x / %r)'
|
||||
% (patched.sb_size, patched.sb_cksum, patched.volume))
|
||||
ck(len(stock_b) == len(patch_b), '分区长度不变(%d B)' % len(patch_b))
|
||||
slot_offs = {o for o, _l in sl.values()}
|
||||
bad_file = 0
|
||||
checked = 0
|
||||
for _p, i in stock.entries():
|
||||
if i.type == 1 or i.data_off in slot_offs:
|
||||
continue # 目录项 / 119 个槽位本身:槽位在下面单独查
|
||||
checked += 1
|
||||
if stock_b[i.data_off:i.data_off + i.size] != patch_b[i.data_off:i.data_off + i.size]:
|
||||
bad_file += 1
|
||||
ck(bad_file == 0, '除 119 个槽位外,其余 %d 个文件的字节**逐字节**不变(%d 处不符)'
|
||||
% (checked, bad_file))
|
||||
chg = [i for i in range(len(stock_b)) if stock_b[i] != patch_b[i]]
|
||||
lo, hi = min(slot_offs), max(o + sl[n][1] for n, (o, _s) in sl.items())
|
||||
ck(chg and chg[0] >= lo and chg[-1] < hi,
|
||||
'变化区间 0x%x..0x%x 完全落在槽位区间 0x%x..0x%x 内'
|
||||
% (chg[0], chg[-1], lo, hi))
|
||||
ident = [n for n, (o, _s) in sl.items() if stock_b[o:o + sl[n][1]] == patch_b[o:o + sl[n][1]]]
|
||||
ck(not ident, '119 个槽位全部被改写(未变的:%s)' % (ident or '无'))
|
||||
|
||||
print('\n' + '=' * 78)
|
||||
print('独立总校验:%d 项通过,%d 项失败' % (ok, fail))
|
||||
print('=' * 78)
|
||||
return 1 if fail else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||