增添 S5 支持

This commit is contained in:
riseforever2026
2026-10-03 00:01:12 +08:00
parent d4dff20600
commit 9a80b9de30
338 changed files with 9463 additions and 64 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+32 -11
View File
@@ -557,7 +557,14 @@ local function buildHome()
local p = newPage()
if not p then return nil end
clockLabel(p)
pageTitle(p, ROLE == 'restore' and 'S4Chg还原' or 'S4Chg', 159, 158)
-- 盘内标题 = 显示名(不再用内部代号 S4Chg);x 按真 MiSans 30px 量出的宽度居中:
-- 'S4e Pt.3' = 123 -> x = (466-123)/2 = 171.5 -> 172
-- 'S4e Pt.3 · 还原' = 220 -> x = (466-220)/2 = 123
if ROLE == 'restore' then
pageTitle(p, 'S4e Pt.3 · 还原', 123, 222)
else
pageTitle(p, 'S4e Pt.3', 172, 125)
end
listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end)
listItem(p, 181, 'ic_replace', ROLE == 'restore' and '开始还原' or '开始替换', function()
pendingVariant = 'replace'
@@ -577,22 +584,30 @@ local function buildHelp()
setClickable(t)
bind(t, function() show(pages.home) end)
listItem(p, 85, 'ic_check', '环境预检查', function() doCheck() end)
desc1 = lab(p, { x = 65, y = 176, w = 338, h = 72,
-- 竖排 4 个元素的 y:76 / 166 / 236 / 326。
-- 原来 85 / 176 / 256 / 344 时,末尾那句 2 行块的**第 2 行**(y 377..410)已经越过圆边
-- (仓库外 check_clip_real.py 实测:replace 盘 47 越界像素,s4e-PAnim 甚至 229 像素),
-- 因为整页内容 88+66+88+66=308px,而 466 圆在 x=65 处最多只给到 y<=394。
-- 4 个元素整体上移 9~22px 后:整页 replace/restore 都是 0 越界、0 溢框(最远像素 228.2 < 233)。
listItem(p, 76, 'ic_check', '环境预检查', function() doCheck() end)
desc1 = lab(p, { x = 65, y = 166, w = 338, h = 72,
text = '检查原始资源状态、分区可写情况等。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
-- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责
listItem(p, 256, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function()
listItem(p, 236, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function()
if ROLE == 'restore' then
setDesc1('本盘是还原盘。要重新换回动画,请安装 S4Chg.face。', C_GRAY)
setDesc1('本盘是还原盘。要重新换回动画,请安装 S4e Pt.3。', C_GRAY)
else
setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4ChgRestore.face。', C_GRAY)
setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4e Pt.3 · 还原。', C_GRAY)
end
end)
lab(p, { x = 65, y = 344, w = 334, h = 72,
-- 换行写死成 2 行:24px 行高 33 -> 2 行 66 才放得进 h=72;
-- 原来 w=334 时这句会被折成 3 行(第 3 行整个在圆外),所以框宽 334 -> 366
-- (x=65 不动;实测行宽 360/306)。
lab(p, { x = 65, y = 326, w = 366, h = 72,
text = ROLE == 'restore' and '把充电动画写回原厂样式。'
or '本盘不含回滚数据(体积减半),还原请装 S4ChgRestore.face。',
or '本盘不含回滚数据(体积减半),\n还原请装 S4e Pt.3 · 还原。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
return p
end
@@ -652,9 +667,15 @@ local function buildAbout()
if IMG.logo then
pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end)
end
-- "OS4 Icons" 28px 量得 138px -> 居中 x = 66 + (334-138)/2 = 164
lab(p, { x = 164, y = 192, w = 138, h = 38, text = ROLE == 'restore' and 'S4Chg还原' or 'S4Chg',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
-- 28px 量得:'S4e Pt.3' = 114 -> 居中 x = 233 - 57 = 176
-- 'S4e Pt.3 · 还原' = 204 -> x = 233 - 102 = 131
if ROLE == 'restore' then
lab(p, { x = 131, y = 192, w = 206, h = 38, text = 'S4e Pt.3 · 还原',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
else
lab(p, { x = 176, y = 192, w = 116, h = 38, text = 'S4e Pt.3',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
end
-- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210
lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0',
text_color = C_GRAY, font_size = 20, text_font = F_VER })
+49 -6
View File
@@ -38,6 +38,13 @@ def prepare(name):
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', name + '.fprj'),
os.path.join(dst, 'watchface', 'fprj', name + '.fprj'))
# projectName 现在就是「手表上显示的表盘名」(见 make_overlays.py 的 DISPLAY_NAME)。
# 官方 scripts/build_face.ps1 先跑 sync_watchface_config.ps1:它按 projectName 找
# watchface/fprj/<projectName>.fprj,找不到就把目录里**唯一**那份 .fprj 改名成它,
# 并把该 fprj 的 <Screen Title> 覆盖成 projectName —— 所以这条流水线里真正进 .face 的
# 显示名就是这个 projectName。注意 fprj 目录里必须**只有一份** .fprj:Compiler.exe 会
# 按目录里每份 .fprj 把资源各打包一遍,多放一份副本会让 .face 直接翻倍。
disp = cfg['projectName']
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'),
os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png'))
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'),
@@ -52,18 +59,54 @@ def prepare(name):
shutil.rmtree(tgt)
shutil.copytree(os.path.join(ovimg, d), tgt)
print(' %s: app/images/%s/ -> %d files' % (name, d, len(os.listdir(tgt))))
return dst
return dst, cfg['projectName']
def build(dst):
def workspace_fprj(dst):
"""把工作区里的 .fprj 归一成 <name>.fprj,并返回它的路径。
我们拷进去时是 <name>.fprj,但 vendor 的 build_face.ps1 会先跑
sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S4e Pt.3)找
<projectName>.fprj,找不到就把目录里**唯一**那份改名过去(<Screen Title> 也被改写成 projectName)。
编完这里再改回 <name>.fprj —— 工作区里始终只有一份 .fprj(放两份会被 Compiler.exe
并成双倍资源),文件名保持项目标识不变,下游 finalize_faces.compile_raw / gen_ui_lua
仍能按 fprj/<name>.fprj 找到它。
多于一份 / 一份都没有都直接报错,不静默放过。
"""
name = os.path.basename(dst)
d = os.path.join(dst, 'watchface', 'fprj')
fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj'))
if len(fs) != 1:
raise SystemExit('%s: watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % (name, 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 build(dst, name, disp):
ps = os.path.join(dst, 'scripts', 'build_face.ps1')
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
# 显式指定产物名:build_face.ps1 默认按 projectName(现在 = 显示名,可能带空格/点)命名产物,
# 这里仍然用 NAME,保证 bin/<NAME>.face、dist/<NAME>.face 这些标识路径不变。
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps,
'-FaceName', name + '.face'],
capture_output=True, text=True, errors='replace')
# sync 已经把 .fprj 改名成 <显示名>.fprj(里面的 <Screen Title> 也写成了显示名);
# 这里改回 NAME,好让下游 finalize_faces.compile_raw 仍按 fprj/<NAME>.fprj 找它。
fprj = workspace_fprj(dst)
print(' fprj 归一:%s' % os.path.relpath(fprj, dst))
for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]:
print(' |', l)
if r.returncode != 0:
print(' !! build failed, rc=', r.returncode)
return r.returncode == 0
return False
# 官方脚本按 projectName 命名产物(bin/<显示名>.face);-FaceName 已经把它压成 NAME,
# 万一没生效,这里再兜一次(内容一样,只差文件名)。
face = os.path.join(dst, 'bin', name + '.face')
alt = os.path.join(dst, 'bin', disp + '.face')
if disp != name and os.path.exists(alt) and not os.path.exists(face):
os.replace(alt, face)
return True
if __name__ == '__main__':
@@ -72,8 +115,8 @@ if __name__ == '__main__':
if only and name != only:
continue
print('== %s (id=%s) ==' % (name, TARGETS[name][0]))
dst = prepare(name)
build(dst)
dst, disp = prepare(name)
build(dst, name, disp)
face = os.path.join(dst, 'bin', name + '.face')
if os.path.exists(face):
print(' OK %s %d bytes' % (face, os.path.getsize(face)))
+5 -1
View File
@@ -6,7 +6,9 @@
体积原因:本盘各 ~16 MB,不再额外复制工具链变体(要的话去掉下面的 SKIP_TOOLCHAIN 即可)
"""
import os, sys, shutil, subprocess, hashlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import WORK, DIST
from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名)
BUILD = os.path.join(WORK, 'build')
TARGETS = {'S4Chg': '362150103', 'S4ChgRestore': '362150103'}
@@ -19,7 +21,9 @@ def compile_raw(name):
if not os.path.exists(exe):
print(' %s: 跳过(没有构建树,先跑 build_faces.py %s)' % (name, name))
return None
fprj = os.path.join(w, 'watchface', 'fprj', name + '.fprj')
# vendor 的 sync_watchface_config.ps1 会把工作区 .fprj 改名成显示名(S4e Pt.3);
# 这里取工作区**唯一**那份 .fprj 并归一回 <name>.fprj,再交给 Compiler.exe 裸编译。
fprj = workspace_fprj(w)
out = os.path.join(WORK, 'raw_' + name)
if os.path.exists(out):
shutil.rmtree(out)
+9
View File
@@ -12,6 +12,7 @@ rb 表里放的是**另一侧字节前 64 B 的指纹**(只给版本校验用
"""
import os, re, sys, json, subprocess
from paths import PROJ, WORK
from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名)
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
NAME = 'S4Chg' if ROLE == 'replace' else 'S4ChgRestore'
@@ -62,6 +63,10 @@ def main():
print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置')
run('make_overlays.py', ROLE)
run('build_faces.py', NAME)
# vendor 的 build_face.ps1 会把工作区 .fprj 改名成 projectName(= 显示名);
# 编完立刻取**唯一**那份并归一回 <NAME>.fprj,后面的 finalize_faces 才有得可读。
print(' fprj 归一:%s' % os.path.relpath(
workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK))
face = open(FACE, 'rb').read()
print(' face %d bytes' % len(face))
@@ -97,6 +102,10 @@ def main():
open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved)
print('第 2 遍:回填后的 Lua(%d bytes),重新编译' % len(resolved))
run('build_faces.py', NAME)
# 同上:finalize_faces.compile_raw 是按 fprj/<NAME>.fprj 路径找工作区 fprj 的,
# vendor 刚把它改名成显示名,这里必须归一(并断言只有一份)后再往下走。
print(' fprj 归一:%s' % os.path.relpath(
workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK))
print(run('finalize_faces.py', NAME).strip())
face2 = open(FACE, 'rb').read()
+6 -2
View File
@@ -5,6 +5,10 @@ from paths import PROJ, WORK
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
NAME = 'S4Chg' if ROLE == 'replace' else 'S4ChgRestore'
# 手表上显示的表盘名:replace / restore 两张盘共用同一个名字(写进 .fprj 的 <Screen Title>,
# 同时就是 watchface.config.json 的 projectName)。只是「显示名」—— NAME、.fprj 文件名、
# dist/<NAME>.face、.work/build/<NAME>、表盘 id 这些路径/标识全部不变。
DISPLAY_NAME = 'S4e Pt.3'
OUT = os.path.join(WORK, 'overlay')
ASSETS = os.path.join(PROJ, 'assets')
@@ -49,12 +53,12 @@ def build(name, role):
os.makedirs(os.path.join(fprj, 'images'), exist_ok=True)
os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True)
cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3",
cfg = {"projectName": DISPLAY_NAME, "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, f'{name}.fprj'), name)
write_fprj(os.path.join(fprj, f'{name}.fprj'), DISPLAY_NAME)
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'))
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+4 -3
View File
@@ -464,7 +464,8 @@ local function buildHome()
local p = newPage()
if not p then return nil end
clockLabel(p)
pageTitle(p, 'OS4 Icons', 159, 158)
-- 盘内标题 = 显示名:'S4e Pt.1' 30px 量得 118 -> x = (466-118)/2 = 174
pageTitle(p, 'S4e Pt.1', 174, 120)
listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end)
listItem(p, 181, 'ic_replace', '开始替换', function()
pendingVariant = 'replace'
@@ -556,8 +557,8 @@ local function buildAbout()
if IMG.logo then
pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end)
end
-- "OS4 Icons" 28px 量得 138px -> 居中 x = 66 + (334-138)/2 = 164
lab(p, { x = 164, y = 192, w = 138, h = 38, text = 'OS4 Icons',
-- 'S4e Pt.1' 28px 量得 109 -> 居中 x = 233 - 109/2 = 178.5 -> 178
lab(p, { x = 178, y = 192, w = 111, h = 38, text = 'S4e Pt.1',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
-- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210
lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0',
+29 -6
View File
@@ -37,6 +37,13 @@ def prepare(name):
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', f'{name}.fprj'),
os.path.join(dst, 'watchface', 'fprj', f'{name}.fprj'))
# projectName 现在就是「手表上显示的表盘名」(见 make_overlays.py 的 DISPLAY_NAME)。
# 官方 scripts/build_face.ps1 先跑 sync_watchface_config.ps1:它按 projectName 找
# watchface/fprj/<projectName>.fprj,找不到就把目录里**唯一**那份 .fprj 改名成它,
# 并把该 fprj 的 <Screen Title> 覆盖成 projectName —— 所以这条流水线里真正进 .face 的
# 显示名就是这个 projectName。注意 fprj 目录里必须**只有一份** .fprj:Compiler.exe 会
# 按目录里每份 .fprj 把资源各打包一遍,多放一份副本会让 .face 直接翻倍。
disp = cfg['projectName']
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'),
os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png'))
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'),
@@ -51,18 +58,34 @@ def prepare(name):
shutil.rmtree(tgt)
shutil.copytree(os.path.join(ovimg, d), tgt)
print(f' {name}: app/images/{d}/ -> {len(os.listdir(tgt))} files')
return dst
return dst, cfg['projectName']
def build(dst):
def build(dst, name, disp):
ps = os.path.join(dst, 'scripts', 'build_face.ps1')
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
# 显式指定产物名:build_face.ps1 默认按 projectName(现在 = 显示名,可能带空格/点)命名产物,
# 这里仍然用 NAME,保证 bin/<NAME>.face、dist/<NAME>.face 这些标识路径不变。
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps,
'-FaceName', f'{name}.face'],
capture_output=True, text=True, errors='replace')
for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]:
print(' |', l)
if r.returncode != 0:
print(' !! build failed, rc=', r.returncode)
return r.returncode == 0
return False
# sync 已经把 .fprj 改名成 <显示名>.fprj(里面的 <Screen Title> 也写成了显示名);
# 这里改回 NAME,好让下游 finalize_faces.compile_raw 仍按 fprj/<NAME>.fprj 找它。
fprj_ident = os.path.join(dst, 'watchface', 'fprj', f'{name}.fprj')
fprj_disp = os.path.join(dst, 'watchface', 'fprj', f'{disp}.fprj')
if disp != name and os.path.exists(fprj_disp) and not os.path.exists(fprj_ident):
os.replace(fprj_disp, fprj_ident)
# 官方脚本按 projectName 命名产物(bin/<显示名>.face);-FaceName 已经把它压成 NAME,
# 万一没生效,这里再兜一次(内容一样,只差文件名)。
face = os.path.join(dst, 'bin', f'{name}.face')
alt = os.path.join(dst, 'bin', f'{disp}.face')
if disp != name and os.path.exists(alt) and not os.path.exists(face):
os.replace(alt, face)
return True
if __name__ == '__main__':
@@ -71,8 +94,8 @@ if __name__ == '__main__':
if only and name != only:
continue
print(f'== {name} (id={TARGETS[name][0]}) ==')
dst = prepare(name)
ok = build(dst)
dst, disp = prepare(name)
build(dst, name, disp)
face = os.path.join(dst, 'bin', f'{name}.face')
if os.path.exists(face):
print(f' OK {face} {os.path.getsize(face)} bytes')
+5 -2
View File
@@ -10,6 +10,9 @@ DEVICE_TYPE = "362" # 与官方模板一致(362 = S4 家族)
ENTRY_WIDGET = "app_lua%2Fmain.lua"
PREVIEW_SIZE = 326 # Compiler.exe 对 362 设备强制 326x326
SCREEN_SIZE = 466
# 手表上显示的表盘名:写进 .fprj 的 <Screen Title>,同样也是 watchface.config.json 的 projectName。
# 只是「显示名」—— 路径/标识(NAME、.fprj 文件名、dist/<NAME>.face、表盘 id)全部不变。
DISPLAY_NAME = 'S4e Pt.1'
WF_ID = "491552801"
FONTS = [r'C:\Windows\Fonts\msyhbd.ttc', r'C:\Windows\Fonts\msyh.ttc',
r'C:\Windows\Fonts\arialbd.ttf', r'C:\Windows\Fonts\arial.ttf']
@@ -48,12 +51,12 @@ def build(name):
os.makedirs(os.path.join(fprj, 'images'), exist_ok=True)
os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True)
cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3",
cfg = {"projectName": DISPLAY_NAME, "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, f'{name}.fprj'), name)
write_fprj(os.path.join(fprj, f'{name}.fprj'), DISPLAY_NAME)
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'))
+2 -2
View File
@@ -75,7 +75,7 @@ def big_button(y=365, text='确认替换'):
# ---------------------------------------------------------------- 1. 首页
PAGE_HOME = [
clock(),
title('OS4 Icons'),
title('S4e Pt.1'),
*item(85, 'MdiLightbulbOutline.png', '快速帮助'),
*item(181, 'MdiBandage.png', '开始替换'),
*item(277, 'MdiInformationOutline.png', '关于'),
@@ -162,7 +162,7 @@ PAGE_ABOUT = [
dict(kind='rect', x=52, y=79, w=362, h=204, bg=PLATE, radius=30),
dict(kind='image', x=194, y=108, w=78, h=78,
src=os.path.join(DOCS, 'logo_preview.png')),
dict(kind='label', x=164, y=192, w=138, h=38, text='OS4 Icons',
dict(kind='label', x=178, y=192, w=111, h=38, text='S4e Pt.1',
size=28, weight=S500, color=WHITE, align='left', vtop=True, lh=38),
dict(kind='label', x=210, y=227, w=45, h=27, text='1.0.0',
size=20, weight=S500, color=GRAY, align='left', vtop=True, lh=27),
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+32 -11
View File
@@ -557,7 +557,14 @@ local function buildHome()
local p = newPage()
if not p then return nil end
clockLabel(p)
pageTitle(p, ROLE == 'restore' and 'S4PAnim还原' or 'S4PAnim', 159, 158)
-- 盘内标题 = 显示名(不再用内部代号 S4PAnim);x 按真 MiSans 30px 量出的宽度居中:
-- 'S4e Pt.2' = 123 -> x = (466-123)/2 = 171.5 -> 172
-- 'S4e Pt.2 · 还原' = 220 -> x = (466-220)/2 = 123
if ROLE == 'restore' then
pageTitle(p, 'S4e Pt.2 · 还原', 123, 222)
else
pageTitle(p, 'S4e Pt.2', 172, 125)
end
listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end)
listItem(p, 181, 'ic_replace', ROLE == 'restore' and '开始还原' or '开始替换', function()
pendingVariant = 'replace'
@@ -577,22 +584,30 @@ local function buildHelp()
setClickable(t)
bind(t, function() show(pages.home) end)
listItem(p, 85, 'ic_check', '环境预检查', function() doCheck() end)
desc1 = lab(p, { x = 65, y = 176, w = 338, h = 72,
-- 竖排 4 个元素的 y:76 / 166 / 236 / 326。
-- 原来 85 / 176 / 256 / 344 时,末尾那句 2 行块的**第 2 行**(y 377..410)已经越过圆边
-- (仓库外 check_clip_real.py 实测:replace 盘 229 越界像素,restore 盘 23 像素),
-- 因为整页内容 88+66+88+66=308px,而 466 圆在 x=65 处最多只给到 y<=394。
-- 4 个元素整体上移 9~22px 后:整页 replace/restore 都是 0 越界、0 溢框(最远像素 228.2 < 233)。
listItem(p, 76, 'ic_check', '环境预检查', function() doCheck() end)
desc1 = lab(p, { x = 65, y = 166, w = 338, h = 72,
text = '检查原始资源状态、分区可写情况等。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
-- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责
listItem(p, 256, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function()
listItem(p, 236, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function()
if ROLE == 'restore' then
setDesc1('本盘是还原盘。要重新换回动画,请安装 S4PAnim.face。', C_GRAY)
setDesc1('本盘是还原盘。要重新换回动画,请安装 S4e Pt.2。', C_GRAY)
else
setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4PAnimRestore.face。', C_GRAY)
setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4e Pt.2 · 还原。', C_GRAY)
end
end)
lab(p, { x = 65, y = 344, w = 334, h = 72,
-- 换行写死成 2 行:24px 行高 33 -> 2 行 66 才放得进 h=72;
-- 原来 w=334 时这句会被折成 3 行(第 3 行整个在圆外),所以框宽 334 -> 366
-- (x=65 不动;实测行宽 360/306)。
lab(p, { x = 65, y = 326, w = 366, h = 72,
text = ROLE == 'restore' and '把压力检测动画写回原厂样式。'
or '本盘不含回滚数据(体积减半),还原请装 S4PAnimRestore.face。',
or '本盘不含回滚数据(体积减半),\n还原请装 S4e Pt.2 · 还原。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
return p
end
@@ -652,9 +667,15 @@ local function buildAbout()
if IMG.logo then
pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end)
end
-- "OS4 Icons" 28px 量得 138px -> 居中 x = 66 + (334-138)/2 = 164
lab(p, { x = 164, y = 192, w = 138, h = 38, text = ROLE == 'restore' and 'S4PAnim还原' or 'S4PAnim',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
-- 28px 量得:'S4e Pt.2' = 113 -> 居中 x = 233 - 56.5 = 176.5 -> 176
-- 'S4e Pt.2 · 还原' = 203 -> x = 233 - 101.5 = 131.5 -> 132
if ROLE == 'restore' then
lab(p, { x = 132, y = 192, w = 205, h = 38, text = 'S4e Pt.2 · 还原',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
else
lab(p, { x = 176, y = 192, w = 115, h = 38, text = 'S4e Pt.2',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
end
-- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210
lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0',
text_color = C_GRAY, font_size = 20, text_font = F_VER })
+49 -6
View File
@@ -38,6 +38,13 @@ def prepare(name):
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', name + '.fprj'),
os.path.join(dst, 'watchface', 'fprj', name + '.fprj'))
# projectName 现在就是「手表上显示的表盘名」(见 make_overlays.py 的 DISPLAY_NAME)。
# 官方 scripts/build_face.ps1 先跑 sync_watchface_config.ps1:它按 projectName 找
# watchface/fprj/<projectName>.fprj,找不到就把目录里**唯一**那份 .fprj 改名成它,
# 并把该 fprj 的 <Screen Title> 覆盖成 projectName —— 所以这条流水线里真正进 .face 的
# 显示名就是这个 projectName。注意 fprj 目录里必须**只有一份** .fprj:Compiler.exe 会
# 按目录里每份 .fprj 把资源各打包一遍,多放一份副本会让 .face 直接翻倍。
disp = cfg['projectName']
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'),
os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png'))
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'),
@@ -52,18 +59,54 @@ def prepare(name):
shutil.rmtree(tgt)
shutil.copytree(os.path.join(ovimg, d), tgt)
print(' %s: app/images/%s/ -> %d files' % (name, d, len(os.listdir(tgt))))
return dst
return dst, cfg['projectName']
def build(dst):
def workspace_fprj(dst):
"""把工作区里的 .fprj 归一成 <name>.fprj,并返回它的路径。
我们拷进去时是 <name>.fprj,但 vendor 的 build_face.ps1 会先跑
sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S4e Pt.2)找
<projectName>.fprj,找不到就把目录里**唯一**那份改名过去(<Screen Title> 也被改写成
projectName)。编完这里再改回 <name>.fprj —— 工作区里始终只有一份 .fprj(放两份会被
Compiler.exe 并成双倍资源),文件名保持项目标识不变,下游 finalize_faces.compile_raw /
gen_ui_lua 仍能按 fprj/<name>.fprj 找到它。
多于一份 / 一份都没有都直接报错,不静默放过。
"""
name = os.path.basename(dst)
d = os.path.join(dst, 'watchface', 'fprj')
fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj'))
if len(fs) != 1:
raise SystemExit('%s: watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % (name, 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 build(dst, name, disp):
ps = os.path.join(dst, 'scripts', 'build_face.ps1')
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
# 显式指定产物名:build_face.ps1 默认按 projectName(现在 = 显示名,可能带空格/点)命名产物,
# 这里仍然用 NAME,保证 bin/<NAME>.face、dist/<NAME>.face 这些标识路径不变。
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps,
'-FaceName', name + '.face'],
capture_output=True, text=True, errors='replace')
# sync 已经把 .fprj 改名成 <显示名>.fprj(里面的 <Screen Title> 也写成了显示名);
# 这里改回 NAME,好让下游 finalize_faces.compile_raw 仍按 fprj/<NAME>.fprj 找它。
fprj = workspace_fprj(dst)
print(' fprj 归一:%s' % os.path.relpath(fprj, dst))
for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]:
print(' |', l)
if r.returncode != 0:
print(' !! build failed, rc=', r.returncode)
return r.returncode == 0
return False
# 官方脚本按 projectName 命名产物(bin/<显示名>.face);-FaceName 已经把它压成 NAME,
# 万一没生效,这里再兜一次(内容一样,只差文件名)。
face = os.path.join(dst, 'bin', name + '.face')
alt = os.path.join(dst, 'bin', disp + '.face')
if disp != name and os.path.exists(alt) and not os.path.exists(face):
os.replace(alt, face)
return True
if __name__ == '__main__':
@@ -72,8 +115,8 @@ if __name__ == '__main__':
if only and name != only:
continue
print('== %s (id=%s) ==' % (name, TARGETS[name][0]))
dst = prepare(name)
build(dst)
dst, disp = prepare(name)
build(dst, name, disp)
face = os.path.join(dst, 'bin', name + '.face')
if os.path.exists(face):
print(' OK %s %d bytes' % (face, os.path.getsize(face)))
+5 -1
View File
@@ -6,7 +6,9 @@
体积原因:本盘各 ~16 MB,不再额外复制工具链变体(要的话去掉下面的 SKIP_TOOLCHAIN 即可)
"""
import os, sys, shutil, subprocess, hashlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import WORK, DIST
from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名)
BUILD = os.path.join(WORK, 'build')
TARGETS = {'S4PAnim': '362150102', 'S4PAnimRestore': '362150102'}
@@ -19,7 +21,9 @@ def compile_raw(name):
if not os.path.exists(exe):
print(' %s: 跳过(没有构建树,先跑 build_faces.py %s)' % (name, name))
return None
fprj = os.path.join(w, 'watchface', 'fprj', name + '.fprj')
# vendor 的 sync_watchface_config.ps1 会把工作区 .fprj 改名成显示名(S4e Pt.2);
# 这里取工作区**唯一**那份 .fprj 并归一回 <name>.fprj,再交给 Compiler.exe 裸编译。
fprj = workspace_fprj(w)
out = os.path.join(WORK, 'raw_' + name)
if os.path.exists(out):
shutil.rmtree(out)
+9
View File
@@ -12,6 +12,7 @@ rb 表里放的是**另一侧字节前 64 B 的指纹**(只给版本校验用
"""
import os, re, sys, json, subprocess
from paths import PROJ, WORK
from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名)
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
NAME = 'S4PAnim' if ROLE == 'replace' else 'S4PAnimRestore'
@@ -62,6 +63,10 @@ def main():
print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置')
run('make_overlays.py', ROLE)
run('build_faces.py', NAME)
# vendor 的 build_face.ps1 会把工作区 .fprj 改名成 projectName(= 显示名);
# 编完立刻取**唯一**那份并归一回 <NAME>.fprj,后面的 finalize_faces 才有得可读。
print(' fprj 归一:%s' % os.path.relpath(
workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK))
face = open(FACE, 'rb').read()
print(' face %d bytes' % len(face))
@@ -97,6 +102,10 @@ def main():
open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved)
print('第 2 遍:回填后的 Lua(%d bytes),重新编译' % len(resolved))
run('build_faces.py', NAME)
# 同上:finalize_faces.compile_raw 是按 fprj/<NAME>.fprj 路径找工作区 fprj 的,
# vendor 刚把它改名成显示名,这里必须归一(并断言只有一份)后再往下走。
print(' fprj 归一:%s' % os.path.relpath(
workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK))
print(run('finalize_faces.py', NAME).strip())
face2 = open(FACE, 'rb').read()
+6 -2
View File
@@ -5,6 +5,10 @@ from paths import PROJ, WORK
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
NAME = 'S4PAnim' if ROLE == 'replace' else 'S4PAnimRestore'
# 手表上显示的表盘名:replace / restore 两张盘共用同一个名字(写进 .fprj 的 <Screen Title>,
# 同时就是 watchface.config.json 的 projectName)。只是「显示名」—— NAME、.fprj 文件名、
# dist/<NAME>.face、.work/build/<NAME>、表盘 id 这些路径/标识全部不变。
DISPLAY_NAME = 'S4e Pt.2'
OUT = os.path.join(WORK, 'overlay')
ASSETS = os.path.join(PROJ, 'assets')
@@ -49,12 +53,12 @@ def build(name, role):
os.makedirs(os.path.join(fprj, 'images'), exist_ok=True)
os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True)
cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3",
cfg = {"projectName": DISPLAY_NAME, "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, f'{name}.fprj'), name)
write_fprj(os.path.join(fprj, f'{name}.fprj'), DISPLAY_NAME)
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'))
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# 大文件 / 中间产物:不进仓库
.work/
inputs/
vendor/
__pycache__/
*.pyc
payload/
payload_restore/
blob_replace/
blob_restore/
+228
View File
@@ -0,0 +1,228 @@
# S5Chg —— 把 S5 41mm(q63) 的充电动画搬到 S5 eSIM 46mm
把 **Xiaomi Watch S5 41mm(q63)** `vela_system.bin` 里的 `charging/charge0..60.bin`(**464×464**),
逐帧**等长覆盖**写进 **Xiaomi Watch S5 eSIM 46mm(p62lte)** `/dev/system` 的同名 61 个槽位
(**480×480**,I8 + RLE)。
> 关系:`s4e-charge` 是「S5 41mm → S4 eSIM」的母项目(只读参考)。本项目沿用它的全部结构
> (两遍构建回填偏移、指纹表拆盘、逐字节等长覆盖、独立校验),只把目标换成 S5 eSIM、
> 帧数改成 61→61、界面重做成 480×480、放大从"同尺寸"改成"464→480 放大"。
## 改了什么
| | |
|---|---|
| 目标 | `inputs/target/vela_system.bin`(S5 eSIM 46mm `/dev/system`)→ `charging/charge0..60.bin`,**61 帧 480×480** I8+RLE,槽位 143,832 ~ 213,511 B(**各不相同**) |
| 源 | `inputs/source/vela_system.bin`(q63 S5 41mm)→ `charging/charge0..60.bin`,**61 帧 464×464** |
| 映射 | **帧号 1:1(n → n)**。两边都是 61 帧,**没有** S4e 那版的 `round(n/56*60)` 比例重采样 |
| 放大 | 源帧 RGB 用 **Lanczos** 放大到 480×480,再把每个像素**吸附回源帧自己的 256 色调色板**(最近的欧氏距离)→ 颜色零新增、零量化,空间上比最近邻放大平滑 |
| 本次 | **61/61 全换**,**每一帧都是"原调色板 + Lanczos"**(没有一帧被降级:没有暗区合并、没有降色数、没有留原厂) |
| 编码 | 压缩 8.69 MB,槽位共 11.27 MB → **还剩 2.57 MB 余量**(最紧的一帧 `charge47` 也余 10,488 B) |
| 校验 | 61/61 回解**逐像素**等于计划图、RLE 恰好解出 usize、inode 表/superblock 逐位不变、槽位外 0 字节变化、回放一致 |
等长的做法(硬约束,一步都不能省):`解码源帧 → Lanczos 放大 480×480 → 吸附回源调色板 →
LVGL RLE 压缩 → 用合法空指令补齐到原 csize`。补齐用 `00 00`(重复 0 次)这类**产出 0 像素**
的指令;另外 `usize_tail()` 会在解码长度比 `usize` 少时**重复最后一个字节**补足
(踩过的坑:RLE 输出比 `usize` 少 1 个像素,设备端整帧不画)。
## 交付物
| 文件 | 字节数 | id / b5 | sha256 |
|---|---|---|---|
| `dist/S5Chg.face` | **11,986,752** | `462150103` / b5=0 | `577147341d363811965b51f46088ed98ea62c946cc8b7d205264577fab4d8066` |
| `dist/S5ChgRestore.face` | **11,986,752** | `462150103` / b5=0 | `8dbfc87acde8b54543fa7f2b54c1b0d7360634c83d6cf139a78df762ff078f2c` |
两份盘**共用同一个表盘 id**(`462150103`):装其中一份就会顶掉另一份,设备上始终只占一份空间。
- `S5Chg.face`:`pay` = 替换后的 61 帧,`rb` = **原厂**前 64 B 指纹(只给版本校验用)
- `S5ChgRestore.face`:`pay` = **原厂**61 帧(逐字节),`rb` = 替换后前 64 B 指纹
预览与报告:
- `docs/decode_preview.png` —— **直接从交付物 .face 里解出来的 61 帧**总览(最硬的证据)
- `docs/face_before_after.png` —— 抽样 5 帧:上排 q63 原帧(464),下排交付物解出的帧(480)
- `docs/before_after.png` —— 生成负载时的对照图(上=源帧,下=磁盘上改后镜像解出的帧)
- `docs/ui_*.png` / `docs/ui_all.png` —— 480×480 界面(9 个规格页 + 8 个还原盘文案变体)
- `docs/selfcheck_report.txt` —— `tools/verify_all.py` 的完整输出(33/33)
- `docs/selfcheck_run.txt` —— **整条重建流水线的原始输出**(8 步,逐步 EXIT=0)
## 真机怎么做
1. 装 `dist/S5Chg.face`
2. 表盘「快速帮助 → 环境预检查」(**只读**,一个字节都不写):确认 `/dev/system` 可读、
卷名是 `system`、61 条负载不越界,并对首/中/尾三条做抽样版本校验(`原厂✓` / `已替换✓`)
3. 首页「开始替换」→ 确认页 → **确认替换** → 等 `成功 61 失败 0`(61 条 ≈ 11.3 MB)
4. 「重启手表」→ 插上充电器看整段动画
5. 还原:装 `dist/S5ChgRestore.face` →「开始还原」→ 重启
输出怎么读(「正在替换」页):每 10 条一行 `> 写入 n/61 成功 x 失败 y`;结束时
`> 共 61 条,成功 x 失败 y`。
**判断成功的唯一标准是 `成功 61 / 失败 0`**;如果出现 `> 版本不符跳过 n 条`,说明设备上的
`/dev/system` 不是算偏移时用的那一版固件 —— 此时**不要重启**,装对应版本的盘或先还原。
写入前每条都做版本校验:读设备上该偏移的前 64 B,必须等于「原厂前 64 B」或「替换后前 64 B」,
**两边都不是就判「版本不符」并跳过 `/dev/system` 剩余记录**(绝不把字节写进别人的文件里)。
写入后整条回读比对,失败自动重试 3 次 + 退化成整条一次 dd。
## 重建
```powershell
# 需要 Python 3 + Pillow + numpy;inputs/ 与 vendor/ 要自己准备(见下)
python tools\make_charge_payload.py all # 61 帧负载 + 整分区镜像(自带 4 项镜像级自证)
python tools\make_dd_payload.py replace # 负载 + 指纹 + blob_replace/
python tools\gen_ui_lua.py replace # 两遍构建 -> dist\S5Chg.face(0 漂移校验)
python tools\verify_anim.py replace # 逐盘校验(11 项)
python tools\make_dd_payload.py restore
python tools\gen_ui_lua.py restore # -> dist\S5ChgRestore.face
python tools\verify_anim.py restore
python tools\verify_all.py # 独立总校验(33 项)+ 预览图 + selfcheck_report.txt
```
或者一条命令跑完全部 8 步(逐步校验、任一步失败即中止,输出留档到 `docs/selfcheck_run.txt`):
```powershell
& ".\tools\selfcheck.ps1"
```
`make_charge_payload.py` 不加 `all` 时只跑 `[0,14,28,42,56]` 这 5 帧(轻量试跑)。
界面相关(改了 `tools/pages.py` / `main.lua` 之后):
```powershell
python tools\make_ui_images.py # assets/ -> ui_images/*.bin(480 档尺寸)
python tools\render_ui.py # 480×480 预览(有标签溢框会直接报出来)
python tools\check_clip.py # 真实渲染像素判圆屏切边(0 越界才算过)
```
### `inputs/` 与 `vendor/` 不进仓库
`.gitignore` 里已经排除(本项目 `.gitignore` 原样沿用,未改动):
| 路径 | 是什么 |
|---|---|
| `inputs/target/vela_system.bin` | 目标固件 p62lte 的 system 分区镜像(84,429,824 B) |
| `inputs/source/vela_system.bin` | 源固件 q63 的 system 分区镜像(100,630,528 B) |
| `inputs/fonts/MiSans-*.ttf` | 可选;只用于本机渲染预览(缺了就退回系统字体,只影响预览) |
| `vendor/LuaDevTemplate-main/` | 官方模板(含 `Compiler.exe`)—— 用默认路径即可,**不要设 `ICON_EDIT_VENDOR`** |
固件分区镜像就是官方 OTA 包(ZIP)里的 `vela_system.bin` 条目,解压出来放到上表位置即可。
## 自证输出(全部真实执行过)
**① 61 条负载长度逐条 == 原槽位长度**
```
目标 S5 eSIM(p62lte) 61 帧(0..60,槽位合计 11.27 MB)
源 S5 41mm(q63) 61 帧(0..60,10.63 MB)
映射:n -> n(1:1) 输出分辨率:480x480(源 464x464 -> Lanczos 放大后吸附回源调色板)
帧 47 <- q63 第 47 帧 槽位 144563 B 编码 134051 B(留白 10488) ... 原调色板+Lanczos
回解自检通过 61 / 61(解码长度恰好 usize 且逐像素等于计划图)
编码方式分布:{'原调色板+Lanczos': 61}
```
`tools/verify_all.py`:`[OK] replace:61 条长度 == 原厂槽位长度 不符 0 条` /
`[OK] restore:61 条长度 == 原厂槽位长度 不符 0 条`;
`tools/verify_anim.py`:`pay 检查 61 条:长度全对 True`
**② 每条负载解出来都是 480×480(不能解码失败)**
```
[OK] replace:61 条都解出 480x480 且 RLE 恰好 usize 不符 0 条
[OK] restore:61 条都解出 480x480 且 RLE 恰好 usize 不符 0 条
```
(`usize` 必须**恰好** 231,424 = 1024 + 480×480,多一字节设备端 INVALID、少一字节不画)
**③ 改后整分区镜像的 inode 表 / superblock 逐位不变**
```
inode 表逐位不变:True(2150 条)
superblock 一致:True(sb_size=84429200 / 84429200,sb_cksum=3275251810 / 3275251810)
逐字节差异:共 11585280 字节,全部落在 61 个槽位内:True(槽位外 0 字节)
[OK] inode 表逐位不变(2150 条) / [OK] superblock 逐位不变 / [OK] 除 61 个槽位外没有任何字节变化
```
**④ 独立校验脚本全绿**
```
=== tools/verify_all.py(只读 dist/*.face + inputs/*/vela_system.bin,不读 .work/)===
61 帧平均差:mean 0.189 max 0.260(第 17 帧) <- 与"独立做的 Lanczos 放大"比对
最像的源帧号与自身不一致的帧:0 个 <- 证明第 n 帧就是 q63 第 n 帧
[OK] replace 盘 rb == 原厂前 64 B(对固件镜像比对) 不符 0 条
[OK] restore 盘 pay == 原厂整条字节(逐字节) 不符 0 条
[OK] restore 盘 rb == replace 盘 pay 前 64 B(两份交付物互证) 不符 0 条
RESULT: 全部通过 —— 33 项通过,0 项失败
=== tools/verify_anim.py replace / restore ===
S5Chg:pay 61 条,rb 61 条 pay 检查:长度全对 True,可解码 True,字节一致 True
全部通过 —— 11 项通过,0 项失败 (restore 同样 11/11)
=== tools/check_clip.py(480×480 圆屏,含 replace/restore 两套文案)===
检查了 17 个页面(含 replace/restore 两套文案):越界页面 0
RESULT: PASS(0 越界、0 折行)
```
**⑤ 两张 .face 的 sha256**
```
S5Chg.face 11986752 b4=0 b5=0 id=b'462150103\x00' sha256[:16]=577147341d363811
S5ChgRestore.face 11986752 b4=0 b5=0 id=b'462150103\x00' sha256[:16]=8dbfc87acde8b545
S5Chg.face sha256=577147341d363811965b51f46088ed98ea62c946cc8b7d205264577fab4d8066
S5ChgRestore.face sha256=8dbfc87acde8b54543fa7f2b54c1b0d7360634c83d6cf139a78df762ff078f2c
```
完整重建 + 自证的原始输出(8 步全部 EXIT=0)留档在 `docs/selfcheck_run.txt`,
显式自证清单在 `docs/selfcheck_report.txt`(33/33)。重跑整条流水线:
```powershell
& ".\tools\selfcheck.ps1" # = 上面 8 条命令,逐步校验,任一步失败即中止
```
## 已知边界 / 没做的部分
1. **没有一帧颜色数被降级**,也没有一帧保留原厂:61/61 都是「原调色板 + Lanczos」。
全帧色数 251~256(即源调色板的实际用量),吸附误差 0.189/255(最大 0.260,第 17 帧)。
2. **没做锐化**:放大只用 Lanczos + 调色板吸附。这是刻意的 —— 锐化会增加索引噪点、
直接吃掉 RLE 余量(最紧的 `charge47` 只剩 10,488 B)。
3. **真机未验证 S5 eSIM 的 `/dev/system` 可写**:这一点在 S4 eSIM 上实测通过(同源平台、
同一套 dd 流程),S5 eSIM 上只能先靠只读的「环境预检查」确认可读 + 偏移对版。
`system/drain/*`、`ota/anim*`、`setupwizard/*` 同理可改,但同样未在本机验证。
4. **偏移锁固件版本**:换固件必须重跑 `make_charge_payload.py all` → `make_dd_payload.py` →
`gen_ui_lua.py`。用错版本时表盘会判「版本不符」并跳过 `/dev/system` 剩余记录,
不会把字节写进别人的文件里。
5. **`Compiler.exe` 的设备表里没有 480 档**(枚举 DeviceType 只有 17 个已知值,圆表且预览
326×326 的只有 362 / 462)→ 这里用 `DeviceType=462` + 326×326 预览图,预览只是表盘列表
里的缩略图,与实际 480×480 画面无关(界面尺寸由 `main.lua` 与 `.fprj` 的 480 决定)。
6. **负载文件必须原样连续放在 resource.bin 里**,偏移才等于 `文件基址 + 魔数长度 + 相对偏移`;
`gen_ui_lua.py` 会断言这一点并做两遍 0 漂移校验。魔数长度是 15 B(曾把 15 写成 16 导致全部偏移差 1)。
7. **写入速度**:偏移都是 512 对齐,所以 195 KB 的记录按 `bs=512` + `bs=4` + `bs=1` 三段写,
不是逐字节;`sync` + 整条回读比对,失败重试 3 次后改用整条一次 dd。
8. **没动的**:`inode` 表、目录项、superblock、任何非 `charging/charge*.bin` 的文件;
仓库里其它项目(`s4e-*`、`s5e-icons`、`s5e-pressure-anim`)与 git(未 commit、未改 `.gitignore`)。
## 目录
```
main.lua 表盘源码(含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符,构建时回填)
tools/ 全部脚本(见下表)
ui_images/ 界面素材(LVGL v9 .bin,8 张,480 档尺寸)
assets/ 设计素材 PNG
dist/ 交付的 .face
docs/ 预览图、对照图、selfcheck_report.txt
inputs/ 固件分区镜像 + 字体(不进仓库,见上)
vendor/ LuaDevTemplate-main(含 Compiler.exe,不进仓库)
.work/ 构建工作区(产物,可删)
```
| 脚本 | 作用 | 相对 s4e-charge |
|---|---|---|
| `tools/paths.py` | 所有路径的唯一入口 | 沿用(未改) |
| `tools/romfs.py` | ROMFS 分区解析 | 沿用(未改) |
| `tools/lvgl_bin.py` | LVGL v9 RLE 编解码 + `pad_rle_safe` | 沿用(未改) |
| `tools/iconpack.py` | ARGB8888/I8 图片编码(界面素材用) | 沿用(未改) |
| `tools/lua_balance.py` | Lua 块配平检查 | 沿用(未改) |
| `tools/make_charge_payload.py` | **改**:目标/源换成 S5e + q63,61→61(n→n),输出 480×480,Lanczos+调色板吸附 | 重写 |
| `tools/make_dd_payload.py` | **改**:魔数 `S5C-PAYv1` / `S5C-FPv1`,脚本文案与设备名 | 小改 |
| `tools/make_overlays.py` | **改**:DeviceType 462 / Screen 480 / 预览 326(按首页规格真渲染) | 改 |
| `tools/build_faces.py` | **改**:盘名 S5Chg / S5ChgRestore,id 462150103 | 小改 |
| `tools/finalize_faces.py` | **改**:同上(b5=0 的忠实产物 + 改 id) | 小改 |
| `tools/gen_ui_lua.py` | **改**:盘名/角色名 | 小改 |
| `tools/verify_anim.py` | **改**:盘名 + 480×480 判据 | 小改 |
| `tools/verify_all.py` | **新增**:独立总校验(33 项,不读 `.work/`)+ 预览图 + 报告 | 新增 |
| `tools/selfcheck.ps1` | **新增**:一条命令跑完上面 8 步(逐步校验、失败即中止、输出留档) | 新增 |
| `tools/pages.py` | **改**:480×480 页面规格(含还原盘文案变体) | 重写 |
| `tools/render_ui.py` | **改**:画布 466→480,输出到 `docs/`,报标签溢框 | 小改 |
| `tools/check_clip.py` | **改**:圆屏 240/240,两套文案都过一遍 | 改 |
| `tools/make_ui_images.py` | **改**:素材尺寸换 480 档(31/18×29/80) | 改 |
| `main.lua` | **改**:480 几何、S5Chg 文案、版本不符**跳过分区剩余记录** | 重写 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Some files were not shown because too many files have changed in this diff Show More