Initial Commit

This commit is contained in:
riseforever2026
2026-10-02 21:34:56 +08:00
parent 5a3867ff25
commit 79bf4cdbf2
915 changed files with 20552 additions and 2 deletions
+80
View File
@@ -0,0 +1,80 @@
"""overlay + 官方模板 -> Compiler.exe -> .face
要点(都是踩过的坑):
* preview.png 必须是 326x326,Compiler.exe 会直接拒绝别的尺寸
* watchfaceId 要带机型家族前缀:S4 家族 = 362150xxx(固件自带表盘是 362150004..7)
* 用 Python 的 copytree 拷目录,别用 PowerShell 的 Copy-Item -Recurse(会拍平)
"""
import os, json, shutil, subprocess, sys
from paths import WORK, TEMPLATE
TPL = TEMPLATE
OVER = os.path.join(WORK, 'overlay')
BUILD = os.path.join(WORK, 'build')
# name -> (watchfaceId, description)
TARGETS = {'S5Icons': ('362150101', 'S5 41mm OS4 icons -> S4 eSIM')}
def prepare(name):
oid, _ = TARGETS[name]
ov = os.path.join(OVER, name)
dst = os.path.join(BUILD, name)
if not os.path.exists(TPL):
raise SystemExit('找不到官方模板:%s\n请把 FangAiden/LuaDevTemplate 放到 vendor/ 下(见 README)' % TPL)
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(TPL, dst)
old = os.path.join(dst, 'watchface', 'fprj', 'LuaDevTemplate.fprj')
if os.path.exists(old):
os.remove(old)
cfg = json.load(open(os.path.join(ov, 'watchface.config.json'), encoding='utf-8'))
cfg['watchfaceId'] = oid
json.dump(cfg, open(os.path.join(dst, 'watchface.config.json'), 'w', encoding='utf-8'),
indent=4, ensure_ascii=False)
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', f'{name}.fprj'),
os.path.join(dst, 'watchface', 'fprj', f'{name}.fprj'))
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'),
os.path.join(dst, 'watchface', 'fprj', 'app', 'lua', 'main.lua'))
img = os.path.join(dst, 'watchface', 'fprj', 'app', 'images')
os.makedirs(img, exist_ok=True)
ovimg = os.path.join(ov, 'watchface', 'fprj', 'app', 'images')
for d in sorted(x for x in os.listdir(ovimg) if os.path.isdir(os.path.join(ovimg, x))):
tgt = os.path.join(img, d)
if os.path.exists(tgt):
shutil.rmtree(tgt)
shutil.copytree(os.path.join(ovimg, d), tgt)
print(f' {name}: app/images/{d}/ -> {len(os.listdir(tgt))} files')
return dst
def build(dst):
ps = os.path.join(dst, 'scripts', 'build_face.ps1')
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
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
if __name__ == '__main__':
only = sys.argv[1] if len(sys.argv) > 1 else None
for name in TARGETS:
if only and name != only:
continue
print(f'== {name} (id={TARGETS[name][0]}) ==')
dst = prepare(name)
ok = build(dst)
face = os.path.join(dst, 'bin', f'{name}.face')
if os.path.exists(face):
print(f' OK {face} {os.path.getsize(face)} bytes')
else:
print(' missing output')
+58
View File
@@ -0,0 +1,58 @@
"""用真实渲染结果判"会不会被圆屏切掉"。
做法:渲染一张**不套圆形遮罩**的 RGBA,然后量所有非透明像素到圆心的距离。
这样自动考虑字体实际字宽、折行、圆角、图片缩放 —— 比按元素外框估算准得多。
"""
import os, sys, math
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PIL import Image, ImageDraw
from render_ui import draw_label, W, H
from pages import PAGES
CX = CY = 233.0
R = 233.0
def render_raw(items):
img = Image.new('RGBA', (W, None) if False else (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
from render_ui import _rgb, font
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':
d.ellipse([it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1],
fill=_rgb(it['bg']) + (255,))
elif k == 'image':
if os.path.exists(it['src']):
im = Image.open(it['src']).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
print(f'{"page":<10} {"max r":>7} {"outside px":>11} worst point / note')
print('-' * 78)
for name, items in PAGES.items():
im = 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:
d = math.hypot(x - CX, y - CY)
if d > worst:
worst, wpt = d, (x, y)
if d > R:
outside += 1
note = '全部在圆内' if worst <= R else f'会被切 {outside} px'
print(f'{name:<10} {worst:>7.1f} {outside:>11} {wpt} {note}')
print('-' * 78)
print(f'圆心 ({CX:.0f},{CY:.0f}) 半径 {R:.0f} 画布 {W}x{H}')
+68
View File
@@ -0,0 +1,68 @@
"""产出最终 .face。
* Compiler.exe 写 byte5 = 0x00 + 自己的随机 id 到 40..49
* 官方 set_face_id.ps1 会把 byte5 改成 10 并写 id
* 固件里 25 个真表盘 byte5 ∈ {0,1},从不为 10
=> 忠实产物 = Compiler.exe 原始输出,只替换 40..49(我们称 b5=0 那份)
同时保留官方工具链那份(b5=10),装不上时可以换。
"""
import os, shutil, subprocess, hashlib
from paths import WORK, DIST
BUILD = os.path.join(WORK, 'build')
TARGETS = {'S5Icons': '362150101'}
def compile_raw(name):
w = os.path.join(BUILD, name)
exe = os.path.join(w, 'watchface', 'tools', 'Compiler.exe')
if not os.path.exists(exe):
print(f' {name}: 跳过(没有构建树,先跑 build_faces.py {name})')
return None
fprj = os.path.join(w, 'watchface', 'fprj', f'{name}.fprj')
out = os.path.join(WORK, 'raw_' + name)
if os.path.exists(out):
shutil.rmtree(out)
os.makedirs(out)
subprocess.run([exe, '-b', fprj, out, f'{name}.face', TARGETS[name]],
capture_output=True, text=True, errors='replace')
p = os.path.join(out, f'{name}.face')
return p if os.path.exists(p) else None
def patch_id(src, dst, wid):
d = bytearray(open(src, 'rb').read())
if d[0:4] != b'\x5a\xa5\x34\x12':
raise SystemExit('bad magic')
if len(wid) > 10:
raise SystemExit('id too long')
d[40:50] = wid.encode() + b'\x00' * (10 - len(wid))
open(dst, 'wb').write(bytes(d))
return dst
def main():
os.makedirs(DIST, exist_ok=True)
report = []
for name, wid in TARGETS.items():
raw = compile_raw(name)
if not raw:
continue
faithful = patch_id(raw, os.path.join(DIST, f'{name}.face'), wid)
tc_src = os.path.join(BUILD, name, 'bin', f'{name}.face')
tc = os.path.join(DIST, f'{name}_toolchain.face')
if os.path.exists(tc_src):
shutil.copyfile(tc_src, tc)
for p in (faithful, tc):
if os.path.exists(p):
d = open(p, 'rb').read()
report.append((os.path.basename(p), len(d), d[4], d[5], d[40:50],
hashlib.sha256(d).hexdigest()[:16]))
print(f'{"file":<32}{"bytes":>10} b4 b5 id sha256[:16]')
for f, n, b4, b5, sid, h in report:
print(f'{f:<32}{n:>10} {b4:<3} {b5:<3} {sid!r:<20} {h}')
print('->', DIST)
if __name__ == '__main__':
main()
+142
View File
@@ -0,0 +1,142 @@
"""规范化构建:表盘源码 + 资源 -> .face(两遍,自动回填 resource.bin 内的偏移)
main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ 占位符
.work/overlay/S5Icons <- 中间产物(回填后的 Lua 落在这里)
.work/build/S5Icons <- 编译工作区(Compiler.exe 的实际输入)
dist/S5Icons.face <- 交付物
为什么要两遍:.face 里各资源的偏移取决于打包顺序,我们靠在 .face 里搜文件字节来定位。
Lua 源码排在所有资源**之后**,所以回填(让 Lua 变长)不会移动前面资源的位置 —— 第一遍量出来的
偏移在第二遍依然成立,脚本最后会逐条校验。
"""
import os, re, sys, json, subprocess
from paths import PROJ, WORK
TOOLS = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(PROJ, 'main.lua')
OVERLAY_APP = os.path.join(WORK, 'overlay', 'S5Icons', 'watchface', 'fprj', 'app')
RESOLVED = os.path.join(OVERLAY_APP, 'lua', 'main.lua')
FACE = os.path.join(WORK, 'build', 'S5Icons', 'bin', 'S5Icons.face')
OFFS = os.path.join(WORK, 'face_payload_offsets.json')
PY = sys.executable
UINAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback',
'ic_qq', 'logo']
def run(script, *args):
r = subprocess.run([PY, os.path.join(TOOLS, script)] + list(args),
capture_output=True, text=True, encoding='utf-8', errors='replace')
if r.returncode != 0:
print(r.stdout[-2000:]); print(r.stderr[-2000:])
raise SystemExit(f'{script} 执行失败')
return r.stdout
def find_blob(face, blob):
hits, start = [], 0
while True:
i = face.find(blob, start)
if i < 0:
break
hits.append(i)
start = i + 1
if len(hits) > 4:
break
return hits
def payload_rows(tag, lua_payload, offs):
src = open(lua_payload, encoding='utf-8').read()
tgt = {}
for m in re.finditer(r'file = "([^"]+)", offset = (\d+), length = (\d+)', src):
tgt[m.group(1)] = (int(m.group(2)), int(m.group(3)))
rows = []
for name, ln, hits, sig in offs[tag]:
if name not in tgt:
continue
blob = min(hits) if tag == 'payload' else max(hits)
to, tl = tgt[name]
rows.append((blob, to, tl))
rows.sort(key=lambda r: r[1])
return rows
def main():
tpl = open(SRC, encoding='utf-8').read()
if '__UIIMG__' not in tpl:
raise SystemExit('main.lua 里没有占位符 —— 它应该是源码模板,不要把回填后的版本存回去')
print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置')
run('make_overlays.py')
run('build_faces.py', 'S5Icons')
face = open(FACE, 'rb').read()
print(f' face {len(face)} bytes')
ui_rows, bad = [], 0
for name in UINAMES:
blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read()
hits = find_blob(face, blob)
if len(hits) != 1:
print(f' !! {name} 命中 {len(hits)} 次(应为 1)')
bad += 1
if hits:
ui_rows.append((hits[0], len(blob), name))
print(f' {name:<12} off=0x{hits[0]:x} len={len(blob)}')
if bad:
raise SystemExit('有资源无法唯一定位,检查是否有重复内容')
# 负载是「魔数 + 顺序拼接」的**单个条目**(blob/),偏移 = 魔数位置 + 魔数长度 + 相对偏移
blobs = json.load(open(os.path.join(PROJ, 'blob', 'offsets.json'), encoding='utf-8'))
pay_blob = open(os.path.join(PROJ, 'blob', 'payload.bin'), 'rb').read()
rb_blob = open(os.path.join(PROJ, 'blob', 'rollback.bin'), 'rb').read()
ml = blobs['magic_len']
pay_base = face.find(pay_blob[:15])
rb_base = face.find(rb_blob[:15])
if face.count(pay_blob[:15]) != 1 or face.count(rb_blob[:15]) != 1:
raise SystemExit('魔数命中次数 != 1(payload %d / rollback %d)'
% (face.count(pay_blob[:15]), face.count(rb_blob[:15])))
# 单条目必须**原样连续**存放,否则「base + 魔数长度 + 相对偏移」不成立
if face.find(pay_blob) != pay_base or face.find(rb_blob) != rb_base:
raise SystemExit('负载不是原样连续存放')
pay = [(pay_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']]
rb = [(rb_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']]
print(f' 单条目:payload @0x{pay_base:x}({len(pay_blob)} B)/ rollback @0x{rb_base:x}'
f'({len(rb_blob)} B),共 {len(pay)} 条记录')
ui_txt = '\n'.join(f' {{{o},{l},"{n}"}},' for o, l, n in ui_rows)
pay_txt = ' pay = {\n' + '\n'.join(f' {{{o},{t},{l}}},' for o, t, l in pay) + '\n },'
rb_txt = ' rb = {\n' + '\n'.join(f' {{{o},{t},{l}}},' for o, t, l in rb) + '\n },'
resolved = (tpl.replace('__UIIMG__', ui_txt)
.replace('__PAY__', pay_txt)
.replace('__RB__', rb_txt))
assert '__UIIMG__' not in resolved and 'pay = {}' not in resolved
os.makedirs(os.path.dirname(RESOLVED), exist_ok=True)
open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved)
print(f'第 2 遍:回填后的 Lua 写入 overlay({len(resolved)} bytes),重新编译')
run('build_faces.py', 'S5Icons')
print(run('finalize_faces.py').strip())
face2 = open(FACE, 'rb').read()
drift = 0
for name in UINAMES:
blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read()
hits = find_blob(face2, blob)
row = [r for r in ui_rows if r[2] == name]
if not row or not hits or hits[0] != row[0][0]:
print(f' !! 偏移漂移 {name}')
drift += 1
for tag, rows, d in (('pay', pay, os.path.join(PROJ, 'payload')),
('rb', rb, os.path.join(PROJ, 'payload_rollback'))):
files = [(f, open(os.path.join(d, f), 'rb').read())
for f in sorted(os.listdir(d)) if f.endswith('.bin')]
ok = sum(1 for off, tgt, ln in rows
if any(len(x) == ln and face2[off:off + ln] == x for _, x in files))
if ok != len(rows):
print(f' !! {tag} 只有 {ok}/{len(rows)} 条吻合'); drift += 1
print(f'偏移校验:{"全部吻合,0 漂移" if drift == 0 else str(drift) + " 处异常"}')
print('交付物:', os.path.join(PROJ, 'dist', 'S5Icons.face'))
if __name__ == '__main__':
main()
+447
View File
@@ -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__)
+49
View File
@@ -0,0 +1,49 @@
"""在编好的 .face 里定位每一份负载,好让 Lua 运行时按偏移从 resource.bin 里读出来
(设备不会把 .face 里的 images/ 解包到文件系统,图片仍然躺在 resource.bin 里)。
"""
import os, sys, json
from paths import PROJ, WORK
FACE = os.path.join(WORK, 'build', 'S5Icons', 'bin', 'S5Icons.face')
PDIR = os.path.join(PROJ, 'payload')
RDIR = os.path.join(PROJ, 'payload_rollback')
OUT = os.path.join(WORK, 'face_payload_offsets.json')
face = open(FACE, 'rb').read()
print(f'face {os.path.basename(FACE)} {len(face)} bytes')
anchor = face.find(b'local lvgl = require("lvgl")')
print(f'lua source starts at 0x{anchor:x} ({anchor})')
out = {}
for tag, d in (('payload', PDIR), ('rollback', RDIR)):
recs = []
dup = 0
for name in sorted(os.listdir(d)):
if not name.endswith('.bin') or name.endswith('payload.lua'):
continue
data = open(os.path.join(d, name), 'rb').read()
if not data:
continue
hits, start = [], 0
while True:
i = face.find(data, start)
if i < 0:
break
hits.append(i)
start = i + 1
if len(hits) > 8:
break
recs.append((name, len(data), hits, data[:16].hex()))
if len(hits) != 1:
dup += 1
out[tag] = recs
print(f'\n{tag}: {len(recs)} files, {dup} with !=1 occurrence')
for name, ln, hits, sig in recs[:6]:
print(f' {name:<34} {ln:>7} hits={[hex(h) for h in hits]}')
if recs:
print(' ...')
json.dump({k: [(n, l, h, s) for n, l, h, s in v] for k, v in out.items()},
open(OUT, 'w', encoding='utf-8'), indent=1)
print('\nsaved', OUT)
+72
View File
@@ -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')
+150
View File
@@ -0,0 +1,150 @@
"""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
+137
View File
@@ -0,0 +1,137 @@
"""Rebuild the replacement payload for the `os.execute` + `dd` route,
in a location-independent layout.
Layout produced:
<out>/payload/ NNN_<name>.bin + payload.lua (bare filenames)
<out>/payload_rollback/ NNN_<name>.bin + payload.lua
<out>/flash.sh linear nsh script (no $( ), no && , no globbing)
<out>/rollback.sh
<out>/payload.json machine-readable manifest
All offsets are multiples of 512 and every emitted length is a multiple of 4,
so `dd bs=4 seek=offset/4 count=length/4` writes exact ranges with no partial
trailing block.
"""
import os, sys, json, hashlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import PROJ, INPUTS, WORK
STOCK = os.path.join(INPUTS, 'target', 'vela_app.bin')
PATCHED = os.path.join(WORK, 's4_from_s5', 'vela_app_s5icons.bin')
OUT = PROJ
recs_in = json.load(open(os.path.join(WORK, 's4_from_s5', 'manifest.json'), encoding='utf-8'))
stock = open(STOCK, 'rb').read()
patched = open(PATCHED, 'rb').read()
assert len(stock) == len(patched)
records = [dict(name='canary_superblock', offset=0, length=32,
data=stock[:32], changed=True)]
assert stock[:8] == b'-rom1fs-'
for r in recs_in['records']:
off, ln = r['offset'], r['length']
records.append(dict(name=r['name'], offset=off, length=ln,
data=patched[off:off + ln], changed=stock[off:off + ln] != patched[off:off + ln]))
emitted = [r for r in records if r['changed']]
dropped = [r for r in records if not r['changed']]
def write_pack(dirname, source):
d = os.path.join(OUT, dirname)
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 = f'{i:03d}_{r["name"]}.bin'
open(os.path.join(d, fn), 'wb').write(source(r))
assert r['offset'] % 4 == 0, r['name'] # 偏移恒定 4 对齐(实际是 512)
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/make_dd_payload.py -- do not hand-edit\n')
f.write('-- dd: -if <this dir>/<file> -of /dev/app bs=4 seek=offset/4 count=length/4\n')
f.write('return {\n')
for r in rows:
f.write(f' {{ name = "{r["name"]}", file = "{r["file"]}", '
f'offset = {r["offset"]}, length = {r["length"]} }},\n')
f.write('}\n')
return rows
lua_rows = write_pack('payload', lambda r: r['data'])
r_rows = write_pack('payload_rollback',
lambda r: stock[r['offset']:r['offset'] + r['length']])
# ---------------------------------------------------------------- 合并成「单个条目」
# 关键:.face 里**每个文件 = 一个资源条目**。逐条打包会做出 480 个条目,
# 把表盘条目数顶到宿主吃不下的量(实测 6.59MB/170 条能跑,9.12MB/490 条黑屏;
# 原厂 resource.bin 到 9.43MB 都能跑 -> 卡的是条目数,不是体积)。
# 所以给编译用的是一份「魔数 + 所有负载顺序拼接」的单文件,偏移在构建时按相对偏移回填。
MAGIC_PAY = b'\x00S4E-PAYv1\x00\xa5\x5a\xc3\x3c' # 15 B,必须全局唯一
MAGIC_RB = b'\x00S4E-RBKv1\x00\x5a\xa5\x3c\xc3'
BLOB = os.path.join(OUT, 'blob')
os.makedirs(BLOB, exist_ok=True)
for f in os.listdir(BLOB):
os.remove(os.path.join(BLOB, f))
pay_blob = MAGIC_PAY + b''.join(r['data'] for r in emitted)
rb_blob = MAGIC_RB + b''.join(stock[r['offset']:r['offset'] + r['length']] for r in emitted)
open(os.path.join(BLOB, 'payload.bin'), 'wb').write(pay_blob)
open(os.path.join(BLOB, 'rollback.bin'), 'wb').write(rb_blob)
rel, off = [], 0
for r in emitted:
rel.append(dict(name=r['name'], rel=off, target=r['offset'], length=r['length']))
off += r['length']
json.dump(dict(magic_len=len(MAGIC_PAY), magic_rb_len=len(MAGIC_RB),
records=rel, pay_bytes=len(pay_blob), rb_bytes=len(rb_blob)),
open(os.path.join(BLOB, 'offsets.json'), 'w', encoding='utf-8'), indent=1)
print('blob/ 单条目打包: payload.bin %d B + rollback.bin %d B(%d 条记录,相对偏移表已写)'
% (len(pay_blob), len(rb_blob), len(rel)))
json.dump(dict(device='Xiaomi Watch S4 eSIM (o62lte)', target='/dev/app',
dd="dd if=<dir>/<file> of=/dev/app bs=4 seek=<offset/4> count=<length/4>",
block_size=4, records=lua_rows,
dropped_identical=[r['name'] for r in dropped]),
open(os.path.join(OUT, 'payload.json'), 'w'), indent=1, ensure_ascii=False)
def script(fname, rows, header, base, verify, tail):
with open(os.path.join(OUT, fname), 'w', encoding='utf-8', newline='\n') as f:
f.write('#!/bin/sh\n')
for line in header:
f.write('# ' + line + '\n')
f.write('\nDEV=/dev/app\nBASE=' + base + '\n\n')
f.write('echo "target check"\nls -l $DEV\n\n')
for r in rows:
# RLE 素材的槽位长度常常不是 4 的倍数 -> 那条退化成 bs=1(偏移本身就对齐)
b = 4 if (r['offset'] % 4 == 0 and r['length'] % 4 == 0) else 1
f.write('echo "== ' + r['name'] + ' ==\n')
f.write(f'dd if=$BASE/{r["file"]} of=$DEV bs={b} seek={r["offset"]//b} '
f'count={r["length"]//b}\n')
if verify:
f.write(f'dd if=$DEV of=/tmp/chk.bin bs={b} skip={r["offset"]//b} '
f'count={r["length"]//b}\n')
f.write(f'cmp /tmp/chk.bin $BASE/{r["file"]}\n\n')
f.write(tail + '\n')
script('flash.sh', lua_rows,
['S5 -> S4 icon replacement, run on-device in nsh (NuttX shell).',
'把 flash.sh 与 payload/ 放到同一个目录,例如 /data/s5icons/',
'然后: sh /data/s5icons/flash.sh 最后: reboot',
'nsh 不支持 $( ) / && / || / 通配符,所以这里是线性展开的。'],
'/data/s5icons/payload', True,
'rm /tmp/chk.bin\necho "all done - run reboot to apply"')
script('rollback.sh', r_rows,
['写回 S4 原厂图标'], '/data/s5icons/payload_rollback', False,
'echo "rollback done - run reboot to apply"')
total = sum(r['length'] for r in lua_rows)
print(f'emitted {len(lua_rows)} records, {total} bytes ({total/1048576:.2f} MB)')
print(f'dropped {len(dropped)} byte-identical record(s): {[r["name"] for r in dropped]}')
for r in lua_rows[:3]:
print(f' {r["name"]:<22} off=0x{r["offset"]:08x} len={r["length"]:<7} seek={r["offset"]//4} count={r["length"]//4}')
print(' ...')
+77
View File
@@ -0,0 +1,77 @@
"""为官方 LuaDevTemplate 生成可编译的 overlay —— 本项目只有 S5Icons 一个表盘。"""
import os, json, shutil
from PIL import Image, ImageFont
from paths import PROJ, WORK
OUT = os.path.join(WORK, 'overlay')
ASSETS = os.path.join(PROJ, 'assets')
DEVICE_TYPE = "362" # 与官方模板一致(362 = S4 家族)
ENTRY_WIDGET = "app_lua%2Fmain.lua"
PREVIEW_SIZE = 326 # Compiler.exe 对 362 设备强制 326x326
SCREEN_SIZE = 466
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']
def font(size):
for p in FONTS:
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def make_preview(path):
"""表盘选择页的缩略图:直接用 assets/cover.png 缩到 326x326。"""
Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA') \
.resize((PREVIEW_SIZE, PREVIEW_SIZE), Image.LANCZOS).save(path)
def write_fprj(path, title):
xml = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
f'<FaceProject DeviceType="{DEVICE_TYPE}">\r\n'
f' <Screen Title="{title}" Bitmap="preview.png">\r\n'
f' <Widget Shape="34" Name="{ENTRY_WIDGET}" '
f'X="0" Y="0" Width="{SCREEN_SIZE}" Height="{SCREEN_SIZE}" Alpha="0" />\r\n'
' </Screen>\r\n</FaceProject>\r\n')
open(path, 'w', encoding='utf-16', newline='').write(xml)
def build(name):
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": 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)
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/) —— .face 里每个文件 = 一个资源条目,
# 逐条放会把条目数顶到宿主吃不下的量(见 make_dd_payload.py 注释)。
blob = os.path.join(PROJ, 'blob')
if not os.path.isdir(blob):
raise SystemExit('缺少 blob/ —— 先跑 tools/make_dd_payload.py')
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)
# 逐条负载与回滚仍然保留在 payload/ 与 payload_rollback/(flash.sh、校验脚本用),
# 但**不进** .face。
return base
if __name__ == '__main__':
build('S5Icons')
print('overlay written to', os.path.join(OUT, 'S5Icons'))
+898
View File
@@ -0,0 +1,898 @@
"""S5 -> S4 launcher icons, but matching the STOCK S4 sizing convention.
Measured difference (why icons looked bigger):
stock S4 art : 136x136 inside the 144x144 canvas (outer 4px fully transparent)
S5 art : 144x144 full-bleed (outer ring painted)
So for every replaced slot whose source art is full-bleed we scale it to
136x136 and centre it in the same 144x144 canvas, then re-encode into the S4
slot's exact colour format. Every emitted blob keeps the slot's byte length, so
ROMFS absolute offsets stay valid (a length change would corrupt the image).
Slots whose S5 counterpart is already inset (== stock) are still copied
verbatim, so identical icons stay bit-exact.
"""
import os, sys, json, shutil, hashlib, struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from romfs import Romfs, img_header
from lvgl_bin import decode_lvgl_bin
import iconpack as ip
from PIL import Image, ImageFilter
from paths import PROJ, INPUTS, WORK
S4 = os.path.join(INPUTS, 'target', 'vela_app.bin')
S5_IMAGES = [os.path.join(INPUTS, 'source', 'vela_app.bin'),
os.path.join(INPUTS, 'source', 'vela_health.bin')]
OUTDIR = os.path.join(WORK, 's4_from_s5')
PATCHED = os.path.join(OUTDIR, 'vela_app_s5icons.bin')
MARGIN = 4
# 出「分组表盘」用:ICON_SKIP_WALKIE=1 -> 只出图标(体积回到 6.58MB 那档)
# ICON_ONLY_WALKIE=1 -> 只出对讲机素材(小表盘)
# 对讲机素材:**默认不打包**(用户 2026-10-02 决定不改)。ICON_WALKIE=1 可打开。
DO_WALKIE = os.environ.get('ICON_WALKIE') == '1'
SKIP_WALKIE = (os.environ.get('ICON_SKIP_WALKIE') == '1') or not DO_WALKIE
ONLY_WALKIE = os.environ.get('ICON_ONLY_WALKIE') == '1'
if not DO_WALKIE:
print('!! 对讲机素材默认不打包(ICON_WALKIE=1 可打开)')
if SKIP_WALKIE:
print('!! ICON_SKIP_WALKIE=1:本次不打包对讲机素材')
if ONLY_WALKIE:
print('!! ICON_ONLY_WALKIE=1:本次只打包对讲机素材')
# Slots the user hand-replaced with their own PNGs. These win over the S5
# firmware source and always go through normalization (crop to the painted area,
# scale to the slot's own painted size, centre) so the emitted blob keeps the
# slot's exact byte length:
# activities / sports_course / interconnect : 112x112 self-made art
# voice_aivs : 144x144 "小爱同学" user PNG
OVERRIDE = {k: os.path.join(PROJ, 'assets', 'override', k + '.png')
for k in ('activities', 'sports_course', 'interconnect', 'voice_aivs')}
os.makedirs(OUTDIR, exist_ok=True)
if os.path.exists(PATCHED):
bak = os.path.join(OUTDIR, 'vela_app_s5icons_fullbleed_backup.bin')
if not os.path.exists(bak):
shutil.copyfile(PATCHED, bak)
print(f'backed up previous (full-bleed) image -> {os.path.basename(bak)}')
def ink_bbox(img, thr=16):
"""bounding box of painted pixels (alpha > thr), or None"""
a = img.convert('RGBA').getchannel('A')
w, h = a.size
px = a.load()
x0, y0, x1, y1 = w, h, -1, -1
for y in range(h):
for x in range(w):
if px[x, y] > thr:
if x < x0: x0 = x
if y < y0: y0 = y
if x > x1: x1 = x
if y > y1: y1 = y
if x1 < 0:
return None
return (x0, y0, x1, y1)
def normalize(img, target, canvas_wh):
"""Scale the painted area so its larger side becomes `target`, keeping the
aspect ratio, and centre it on a transparent canvas of the SLOT size.
canvas_wh is the slot's own w,h -- the source may be any size (the S5 art is
144x144, the Band 11 art the user dropped in is 112x112), so the output
canvas must not be taken from the source.
"""
cw, ch = canvas_wh
bb = ink_bbox(img)
if bb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
x0, y0, x1, y1 = bb
bw, bh = x1 - x0 + 1, y1 - y0 + 1
s = float(target) / float(max(bw, bh))
if abs(s - 1.0) < 1e-6 and (bw, bh) == (cw, ch):
return img.convert('RGBA')
art = img.convert('RGBA').crop((x0, y0, x1 + 1, y1 + 1))
nw, nh = max(1, int(round(bw * s))), max(1, int(round(bh * s)))
art = art.resize((nw, nh), Image.LANCZOS)
if s > 1.05:
# upsampling always softens; give the edges some bite back
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
canvas.paste(art, ((cw - nw) // 2, (ch - nh) // 2))
return canvas
s4 = Romfs(S4)
s4t = ip.targets(s4)
s5 = {}
S5ROM = []
for path in S5_IMAGES:
r = Romfs(path)
S5ROM.append(r)
tag = r.volume
for key, ino in ip.targets(r).items():
if key in s5:
continue
blob = r.read(ino)
_, img = decode_lvgl_bin(blob)
hh = img_header(blob)
# an I8 source ships its own 256-entry palette: reuse it verbatim
pal = blob[12:12 + 1024] if (hh and hh['cf'] == 0x0a and len(blob) >= 1036) else None
s5[key] = (blob, img, tag, ino.size, pal)
print(f'S4 slots: {len(s4t)} S5 icons: {len(s5)}\n')
plan, skipped, failed, new_blobs, extra_ino = [], [], [], {}, {}
for key, ino in sorted(s4t.items()):
if ONLY_WALKIE:
break
if key not in s5:
skipped.append((key, 'no S5 counterpart'))
continue
h4 = img_header(s4.read(ino))
blob5, img5, tag5, size5, pal5 = s5[key]
slot_len = ino.size
how = None
# a hand-replaced PNG wins over the firmware source
ov = OVERRIDE.get(key)
if ov and os.path.exists(ov):
img5 = Image.open(ov).convert('RGBA')
blob5 = b'' # force re-encode, never verbatim copy
pal5 = None # the S5 palette does not describe THIS art
tag5 = 'user-png %dx%d' % img5.size
# what sizing does THIS slot use in the stock image?
_, stock_img = decode_lvgl_bin(s4.read(ino))
sb = ink_bbox(stock_img) if stock_img is not None else None
target = max(sb[2] - sb[0] + 1, sb[3] - sb[1] + 1) if sb else 136
nb = ink_bbox(img5) if img5 is not None else None
need = nb is not None and (nb[2] - nb[0] + 1, nb[3] - nb[1] + 1) != (target, target)
if need:
src = normalize(img5, target, (h4["w"], h4["h"]))
if src.size != (h4['w'], h4['h']):
src = src.resize((h4['w'], h4['h']), Image.LANCZOS)
try:
out = ip.encode(src, h4['cf'], h4['w'], h4['h'], h4['flags'], pad_to=slot_len, palette=pal5)
except ValueError:
out = None # slot is RLE-compressed: an uncompressed encode
# cannot fit, so fall through to the copy path
if out is not None and len(out) == slot_len:
how = 'normalize->%d' % target
else:
out = None
if how is None:
if len(blob5) == slot_len:
out, how = blob5, 'copy'
elif img5 is None:
failed.append((key, 'S5 source not decodable'))
continue
else:
src = img5
if src.size != (h4['w'], h4['h']):
src = src.resize((h4['w'], h4['h']), Image.LANCZOS)
out = ip.encode(src, h4['cf'], h4['w'], h4['h'], h4['flags'], palette=pal5)
if len(out) != slot_len:
failed.append((key, f'encoded {len(out)} != slot {slot_len}'))
continue
how = 're-encode'
plan.append(dict(name=key, offset=ino.data_off, length=slot_len,
s4_format=ip.FMT.get(h4['cf'], f"0x{h4['cf']:02x}"),
s4_flags=h4['flags'], s5_bytes=len(blob5), s5_from=tag5,
method=how))
new_blobs[key] = out
# ---------------------------------------------------------------- 控制中心磁贴(q63 -> S4 eSIM)
# S4 eSIM / S5 eSIM 的控制中心是「蓝色主题」(选中 = 实心蓝盘,未选中 = 深色盘);
# q63(S5 41mm / OS4)是「浅色主题」(选中 = 白盘 + 蓝字形)。整批一起搬才协调。
CC_DIR = 'control_center/icon/'
# 除了整目录的控制中心磁贴,再点名搬这几张系统 UI 素材(和 S5 eSIM 那份一致)。
# 健康分区的两条(notification_stand / notification_vigor)本来就和 q63 逐字节相同,
# 而且 S4 这边只解出了 app 分区,所以列在这里也会被跳过。
SYS_PATHS = [
'system/icon/lock_icon.bin',
'common/icon/system_lock.bin',
'common/icon/lock.bin',
'system/icon/unlock.bin',
'common/icon/quiet_mode.bin',
'common/icon/unread_msg.bin',
'notifications/default.bin',
'notifications/default_small.bin',
'notifications/no_message.bin',
'settings/icon/notify.bin',
'settings/disturb/confirm.bin',
'esimsms/icon/esim_sms_reminder.bin',
'esimsms/icon/sms_no_read.bin',
'esimsms/icon/sms_reminder_watch.bin',
'phone/icon/send_sms_new.bin',
'mijia/enable_donot_disturb.bin',
'mijia/disable_donot_disturb.bin',
# 压力 App 的小表情(S4 没有 health 分区,这些都在 /dev/app;q63 那份在它的 health 里)
'pressure/icon/presure_relax.bin',
'pressure/icon/presure_mild.bin',
'pressure/icon/presure_mid.bin',
'pressure/icon/presure_severe.bin',
'pressure/icon/presure_relax32.bin',
'pressure/icon/presure_mild32.bin',
'pressure/icon/presure_mid32.bin',
'pressure/icon/presure_severe32.bin',
'pressure/icon/presure_relax96.bin',
'pressure/icon/presure_mild96.bin',
'pressure/icon/presure_mid96.bin',
'pressure/icon/presure_severe96.bin',
'pressure/icon/presure_icon.bin',
'pressure/icon/press_grade1.bin',
'pressure/icon/press_grade2.bin',
'pressure/icon/press_grade3.bin',
'pressure/icon/press_grade4.bin',
'pressure/icon/pressure_eomji_bg.bin',
'pressure/icon/pressure_eomji_widbg.bin',
]
s4_all = {p: i for p, i in s4.entries() if i.type != 1}
s5_all = {}
for _r in S5ROM:
for _p, _i in _r.entries():
if _i.type != 1 and _p not in s5_all:
s5_all[_p] = (_r, _i)
print('\n--- 控制中心磁贴 + 系统 UI 素材(q63 -> S4 eSIM)---')
cc_paths = set()
_all_paths = sorted(p for p in s4_all if p.startswith(CC_DIR) and p.endswith('.bin'))
for _sp in SYS_PATHS:
if _sp in s4_all and _sp not in _all_paths:
_all_paths.append(_sp)
if ONLY_WALKIE:
_all_paths = []
for path in _all_paths:
tino = s4_all[path]
got = s5_all.get(path)
if got is None:
skipped.append((path, 'q63 没有这个文件')); continue
qrom, oino = got
tgt_blob = s4.read(tino)
src_blob = qrom.read(oino)
if src_blob == tgt_blob:
skipped.append((path, '两边字节完全相同')); continue
th = img_header(tgt_blob) if len(tgt_blob) >= 12 else None
sh = img_header(src_blob) if len(src_blob) >= 12 else None
if len(src_blob) == len(tgt_blob):
new, how = src_blob, 'copy(等长)'
elif th and sh and th['cf'] == sh['cf']:
timg = decode_lvgl_bin(tgt_blob)[1]
simg = decode_lvgl_bin(src_blob)[1]
if timg is None or simg is None:
skipped.append((path, '无法解码(cf=0x%02x)' % (th['cf'] if th else 0))); continue
sb = ink_bbox(timg)
nb = ink_bbox(simg)
if sb and nb:
tw, thh = sb[2] - sb[0] + 1, sb[3] - sb[1] + 1
art = simg.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
if art.size != (tw, thh):
up = tw > art.size[0] or thh > art.size[1]
art = art.resize((tw, thh), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
canvas = Image.new('RGBA', (th['w'], th['h']), (0, 0, 0, 0))
canvas.paste(art, (sb[0], sb[1]))
out = canvas
else:
out = simg
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
how = 'rescale %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
else:
skipped.append((path, '长度/格式不支持')); continue
if len(new) != len(tgt_blob):
failed.append((path, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
is_cc = path.startswith(CC_DIR)
name = ('cc_' if is_cc else 'sys_') + path.split('/')[-1][:-4]
new_blobs[name] = new
extra_ino[name] = tino
cc_paths.add(path)
plan.append(dict(name=name, kind=('cc' if is_cc else 'sysicon'), path=path, offset=tino.data_off, length=len(tgt_blob),
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']) if th else '?',
s4_flags=th['flags'] if th else 0, s5_bytes=len(src_blob),
s5_from='q63 %s' % os.path.basename(qrom.path), method=how))
print(' %-46s %-22s %s' % (path, how, ''))
# ------------------------------------------------- 对讲机素材(用户提供的整套 PNG)
# walkie_talkie/ 下的槽位几乎**全是 I8+RLE 压缩**的:直接编码长度会变(ROMFS 偏移全平移),
# 所以走 ip.encode_into_slot():量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize,
# 保证与原槽位逐字节等长。
WALKIE_SRC = os.path.join(PROJ, 'assets', 'walkie_talkie')
walk_paths = set()
mijia_paths = set()
print('\n--- 对讲机素材 walkie_talkie(用户 PNG)-> S4 eSIM ---')
walkie_n = 0
if SKIP_WALKIE or not os.path.isdir(WALKIE_SRC):
print(' 跳过这一组(ICON_SKIP_WALKIE=%s / 目录存在=%s)'
% (SKIP_WALKIE, os.path.isdir(WALKIE_SRC)))
else:
_rels = []
for _root, _, _files in os.walk(WALKIE_SRC):
for _f in _files:
if _f.lower().endswith('.png'):
_rels.append(os.path.relpath(os.path.join(_root, _f), WALKIE_SRC).replace('\\', '/'))
for rel in sorted(_rels):
path = 'walkie_talkie/' + rel[:-4] + '.bin'
tino = s4_all.get(path)
if tino is None:
skipped.append((path, '目标固件没有这个槽位')); continue
tgt_blob = s4.read(tino)
img = Image.open(os.path.join(WALKIE_SRC, rel))
new, how = ip.encode_into_slot(img, tgt_blob, tino.size)
if new is None:
skipped.append((path, how)); continue
if new == tgt_blob:
skipped.append((path, '与现有一致')); continue
th = img_header(tgt_blob)
name = 'walk_' + rel[:-4].replace('/', '_')
new_blobs[name] = new
extra_ino[name] = tino
walk_paths.add(path)
plan.append(dict(name=name, kind='walkie', path=path, offset=tino.data_off,
length=tino.size,
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']) if th else '?',
s4_flags=th['flags'] if th else 0, s5_bytes=0,
s5_from='用户 PNG assets/walkie_talkie/' + rel, method=how))
walkie_n += 1
if walkie_n <= 6 or walkie_n % 40 == 0:
print(' %-52s %-18s %d B' % (path, how, tino.size))
print(' 对讲机素材写入 %d 条' % walkie_n)
# ------------------------------------------------- 控制中心顶部状态区(q63 -> S4 eSIM)
# q63 的顶栏素材在 control_center/bar/:wifi 4 档 / 蜂窝信号 5 档 / 3G·4G 文字 /
# 蓝牙连接·断开 / 飞行模式 / 定位 / 勿扰 / 静音。本机同名槽位在 control_center/icon/(48x48 I8)。
# 尺寸不同的(q63 那几张 36x36)按**实心范围**归一化:把 q63 的实心图缩放到本机槽位的实心范围、
# 贴在同样的原点 —— 位置与视觉重量和本机原有图标一致。
CC_TOP = [
('control_center/icon/network_4g.bin', 'control_center/bar/network_4g.bin', '4G 文字'),
('control_center/icon/network_3g.bin', 'control_center/bar/network_3g.bin', '3G 文字'),
('control_center/icon/signal_strength.bin', 'control_center/bar/signal_strength0.bin', '蜂窝信号 0'),
('control_center/icon/signal_strength1.bin', 'control_center/bar/signal_strength1.bin', '蜂窝信号 1'),
('control_center/icon/signal_strength2.bin', 'control_center/bar/signal_strength2.bin', '蜂窝信号 2'),
('control_center/icon/signal_strength3.bin', 'control_center/bar/signal_strength3.bin', '蜂窝信号 3'),
('control_center/icon/signal_strength4.bin', 'control_center/bar/signal_strength4.bin', '蜂窝信号 4'),
('control_center/icon/air_mode_conn.bin', 'control_center/bar/air.bin', '飞行模式'),
('control_center/icon/bt_connect.bin', 'control_center/bar/connect.bin', '蓝牙已连接手机'),
('control_center/icon/bt_disconnect.bin', 'control_center/bar/disconnect.bin', '蓝牙未连接'),
('control_center/icon/location.bin', 'control_center/bar/local.bin', '定位'),
('control_center/icon/wifi_level_0.bin', 'control_center/bar/wifi_level_0.bin', 'WiFi 0'),
('control_center/icon/wifi_level_1.bin', 'control_center/bar/wifi_level_1.bin', 'WiFi 1'),
('control_center/icon/wifi_level_2.bin', 'control_center/bar/wifi_level_2.bin', 'WiFi 2'),
('control_center/icon/wifi_level_3.bin', 'control_center/bar/wifi_level_3.bin', 'WiFi 3'),
]
# 本机把「3G/4G 文字 + 信号格」合成在一张图里,而 q63 是分开的两张 —— 按本机原有的两段实心区域
# 分别填:左边文字段用 q63 的 3G/4G,右边信号段用 q63 的 signal_strengthN。
CC_COMPOSED = []
for _net in ('3g', '4g'):
for _n in range(1, 5):
CC_COMPOSED.append(('control_center/icon/network_%s_signal_%d.bin' % (_net, _n),
'control_center/bar/network_%s.bin' % _net,
'control_center/bar/signal_strength%d.bin' % _n))
CC_COMPOSED.append(('control_center/icon/network_%s_no_signal.bin' % _net,
'control_center/bar/network_%s.bin' % _net,
'control_center/bar/signal_strength0.bin'))
# 手机电量(本机 phone/icon/battery_level_*):q63 同名,只是高度不同
for _n in range(0, 11):
CC_TOP.append(('phone/icon/battery_level_%d.bin' % _n,
'phone/icon/battery_level_%d.bin' % _n, '手机电量 %d' % _n))
def _split_two(img):
"""把「文字 + 信号格」这类合成图按空列切成左右两段,返回两段 bbox(失败返回 None)。"""
a = img.convert('RGBA').getchannel('A')
w, h = a.size
px = a.load()
cols = [any(px[x, y] > 16 for y in range(h)) for x in range(w)]
gaps, run = [], None
for x, on in enumerate(cols):
if not on:
run = x if run is None else run
else:
# 只认「两侧都有实心」的空隙:开头的空白段(0..n)不是切分点
if run is not None and run > 0 and x - run >= 1:
gaps.append((run, x - 1))
run = None
if not gaps:
return None
# 取**最左边**那个「两侧都有实心」的空隙 = 文字与信号格之间的分界
# (取最宽的会落在信号格之间,把格数挤进一条缝里)
g0, g1 = gaps[0]
left = [x for x in range(0, g0) if cols[x]]
right = [x for x in range(g1 + 1, w) if cols[x]]
if not left or not right:
return None
def bbox(xs):
ys = [y for y in range(h) for x in xs if px[x, y] > 16]
return (min(xs), min(ys), max(xs), max(ys))
return bbox(left), bbox(right)
def normalize_to_bbox(src_img, target_bb, canvas_wh, fit=False):
"""把 src 的**实心区域**放进 target_bb。
fit=False:拉伸填满 target_bb(槽位画法本来就一致时用)
fit=True :**等比**缩放到能放进 target_bb 的最大尺寸并居中(不拉伸 —— 合成图里
「信号格」这类会因横纵比例不同被压扁,必须走这条)
"""
cw, ch = canvas_wh
if target_bb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
sb = ink_bbox(src_img)
if sb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
tw, thh = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
art = src_img.convert('RGBA').crop((sb[0], sb[1], sb[2] + 1, sb[3] + 1))
if fit:
s = min(tw / art.size[0], thh / art.size[1])
nw, nh = max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))
if (nw, nh) != art.size:
up = s > 1.0
art = art.resize((nw, nh), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
pos = (target_bb[0] + (tw - nw) // 2, target_bb[1] + (thh - nh) // 2)
else:
if art.size != (tw, thh):
up = tw > art.size[0] or thh > art.size[1]
art = art.resize((tw, thh), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
pos = (target_bb[0], target_bb[1])
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
canvas.paste(art, pos)
return canvas
print('\n--- 控制中心顶部状态区(q63 control_center/bar -> S4 eSIM)---')
cc_top_n = 0
for tpath, spath, note in CC_TOP:
tino = s4_all.get(tpath)
got = s5_all.get(spath)
if tino is None:
skipped.append((tpath, '本机没有这个槽位')); continue
if got is None:
skipped.append((spath, 'q63 没有这个素材')); continue
qrom, oino = got
tgt_blob = s4.read(tino)
src_blob = qrom.read(oino)
th = img_header(tgt_blob)
timg = decode_lvgl_bin(tgt_blob)[1]
simg = decode_lvgl_bin(src_blob)[1]
if timg is None or simg is None:
skipped.append((tpath, '解码失败')); continue
sb = ink_bbox(timg)
out = normalize_to_bbox(simg, sb, (th['w'], th['h']))
pal = src_blob[12:12 + 1024] if img_header(src_blob)['cf'] == ip.CF_I8 else None
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
if len(new) != len(tgt_blob):
failed.append((tpath, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
# 与 sys 组用同名 -> 同一槽位只保留一条记录(后者覆盖前者)
name = ('cc_' if tpath.startswith(CC_DIR) else 'sys_') + tpath.split('/')[-1][:-4]
new_blobs[name] = new
extra_ino[name] = tino
cc_paths.add(tpath)
plan.append(dict(name=name, kind='cctop', path=tpath, offset=tino.data_off, length=tino.size,
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
s5_bytes=len(src_blob), s5_from='q63 %s' % spath, method='归一化(%s)' % note))
cc_top_n += 1
print(' %-46s <- %-40s %s' % (tpath, spath, note))
print(' 顶栏状态区写入 %d 条' % cc_top_n)
print('\n--- 控制中心「3G/4G+信号格」合成图(S4e 的 network_* + S5e 的 signal_strength)---')
# 合成来源按要求改:文字取 **本机(S4e)** 的 network_3g/4g,信号格取 **S5e** 的 signal_strength*
# (S5e 表盘里的当前版本,若它已被移植过就是新的蓝色版;没有就用它的原厂版)
S5E_IMG = os.path.join(PROJ, '..', 's5e-icons', '.work', 's5e_from_os4', 'vela_app_os4.bin')
_s5e = Romfs(S5E_IMG) if os.path.exists(S5E_IMG) else None
_s5e_tab = {q: i for q, i in _s5e.entries()} if _s5e else {}
print(' S5e 信号格来源: %s' % ('S5e 表盘镜像' if _s5e else '(缺,回退 q63)'))
cc_comp_n = 0
for tpath, tpath_text, tpath_bars in CC_COMPOSED:
tino = s4_all.get(tpath)
gt = s5_all.get(tpath_text)
gb = s5_all.get(tpath_bars)
if tino is None or gt is None or gb is None:
skipped.append((tpath, '缺槽位或 q63 缺素材')); continue
tgt_blob = s4.read(tino)
th = img_header(tgt_blob)
timg = decode_lvgl_bin(tgt_blob)[1]
if timg is None:
skipped.append((tpath, '本机图解码失败')); continue
parts = _split_two(timg)
if parts is None:
skipped.append((tpath, '切不开两段(布局不同)')); continue
(tx0, ty0, tx1, ty1), (bx0, by0, bx1, by1) = parts
canvas = Image.new('RGBA', (th['w'], th['h']), (0, 0, 0, 0))
pal = None
ow = th['w'] - 4 # 用满整张宽度(左右各留 2px)
x0 = 2
gap = 3
tw_new = max(10, round(ow * 0.44))
bw_new = max(10, ow - tw_new - gap)
text_bb = (x0, 0, x0 + tw_new - 1, th['h'] - 1)
bars_x0 = x0 + tw_new + gap
bars_bb = (bars_x0, 0, bars_x0 + bw_new - 1, th['h'] - 1)
# 文字:优先用**本次刚写好的本机 network_3g/4g**(就是 S4e 上的那张)
tname = ('cc_' if tpath_text.startswith(CC_DIR) else 'sys_') + tpath_text.split('/')[-1][:-4]
text_img = decode_lvgl_bin(new_blobs[tname])[1] if tname in new_blobs else None
if text_img is None:
text_img = decode_lvgl_bin(gt[0].read(gt[1]))[1]
canvas.alpha_composite(normalize_to_bbox(text_img, text_bb, (th['w'], th['h']), fit=True))
pal = new_blobs[tname][12:12 + 1024] if tname in new_blobs else None
# 信号格:**S5e** 的同名槽位(signal_strength / signal_strength1..4)
lvl = tpath_bars.split('signal_strength')[-1][:-4] # '', '1'...'4'
s5e_name = 'control_center/icon/signal_strength%s.bin' % lvl
bars_img = None
if _s5e is not None and s5e_name in _s5e_tab:
bars_img = decode_lvgl_bin(_s5e.read(_s5e_tab[s5e_name]))[1]
if bars_img is None:
bars_img = decode_lvgl_bin(gb[0].read(gb[1]))[1]
if bars_img is None:
skipped.append((tpath, '信号格来源解码失败')); continue
canvas.alpha_composite(normalize_to_bbox(bars_img, bars_bb, (th['w'], th['h']), fit=True))
new = ip.encode(canvas, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
if len(new) != len(tgt_blob):
failed.append((tpath, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
# 与 sys 组用同名 -> 同一槽位只保留一条记录(后者覆盖前者)
name = ('cc_' if tpath.startswith(CC_DIR) else 'sys_') + tpath.split('/')[-1][:-4]
new_blobs[name] = new
extra_ino[name] = tino
cc_paths.add(tpath)
plan.append(dict(name=name, kind='cctop', path=tpath, offset=tino.data_off, length=tino.size,
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
s5_bytes=0, s5_from='S4e %s + S5e %s' % (tpath_text, s5e_name),
method='合成(文字+信号格)'))
cc_comp_n += 1
print(' %-46s <- S4e %s + S5e %s' % (tpath, tpath_text.split('/')[-1], s5e_name.split('/')[-1]))
print(' 合成图写入 %d 条' % cc_comp_n)
# ------------------------------------------------- 米家 / 融合中心(控制中心里的米家设备图标)
# 规则:q63 的 fusion_center/** 与 mijia/** 里,**同名路径**在本机也有的就移植
# (S5e 还有一份 fusion_center/gauss/**,同名的一并写)。画布尺寸两边常常不同
# (q63 是 116/64,本机是 96/128),所以按**实心范围**对齐 + 转成本机槽位的格式。
PLATE_KEEP = ('headset',) # 只有这种「裸图案」槽位保留底版 + 图案归位
# q63 的「柔光玻璃」底盘(干净的深色玻璃圆盘 + 发丝高光环)—— 用它当底版
_glass = None
_g = s5_all.get('fusion_center/fg.bin')
if _g is not None:
_glass = decode_lvgl_bin(_g[0].read(_g[1]))[1]
if _glass is not None:
_glass = _glass.convert('RGBA')
print('柔光玻璃盘: q63 fusion_center/fg.bin %dx%d' % _glass.size)
MIJIA_PREFIX = ('fusion_center/', 'mijia/')
# 控制中心的米家/融合中心那组:**默认不打包**(用户 2026-10-02 决定不改)。ICON_MIJIA=1 可打开。
DO_MIJIA = os.environ.get('ICON_MIJIA') == '1'
def plate_glyph_bbox(img, tol=26, min_plate=0.30):
"""目标图标里「底版上的图案」的 bbox。
本机很多设备图标是「深色圆底版 + 亮色图案」;q63 有的素材是**裸图案(没有底版)**,
这时按实心范围缩放会把图案放大到整个底版 —— 必须改成:保留底版,把裸图案放回
原图案的位置/尺寸。做法:底版色 = 出现最多的颜色,其余像素的 bbox 就是原图案。
"""
rgba = img.convert('RGBA')
w, h = rgba.size
px = rgba.load()
cnt = {}
for y in range(h):
for x in range(w):
p = px[x, y]
if p[3] > 16:
cnt[p] = cnt.get(p, 0) + 1
if not cnt:
return None
plate, n = max(cnt.items(), key=lambda kv: kv[1])
if n >= min_plate * w * h:
xs, ys = [], []
for y in range(h):
for x in range(w):
p = px[x, y]
if p[3] > 16 and (abs(p[0] - plate[0]) + abs(p[1] - plate[1]) + abs(p[2] - plate[2])) > tol:
xs.append(x); ys.append(y)
if xs:
return (min(xs), min(ys), max(xs), max(ys))
# 兜底:底版是渐变(没有主导纯色)时,用**亮度**找亮图案(深色底版上的白图案)
lum = {}
mx = 0
for y in range(h):
for x in range(w):
p = px[x, y]
if p[3] > 16:
v = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]
lum[(x, y)] = v
mx = max(mx, v)
if not lum or mx <= 0:
return None
thr = 0.62 * mx
xs = [x for (x, y), v in lum.items() if v >= thr]
ys = [y for (x, y), v in lum.items() if v >= thr]
if not xs:
return None
bb = (min(xs), min(ys), max(xs), max(ys))
if (bb[2] - bb[0] + 1) * (bb[3] - bb[1] + 1) > 0.92 * w * h:
return None # 亮像素几乎铺满 -> 它本身就是全幅图案,不做归位
return bb
def ink_ratio(img):
"""实心范围占画布的比例(两个方向取大者)—— 接近 1.0 说明是"满幅/带底版"的图。"""
bb = ink_bbox(img)
if bb is None:
return 0.0
w, h = img.size
return max((bb[2] - bb[0] + 1) / w, (bb[3] - bb[1] + 1) / h)
def overlay_glyph_in_place(tgt_blob, th, src_img, target_bb):
"""在**索引层**把 q63 的裸图案画进本机 I8 图标:底版像素一个比特都不动。
为什么必须这么做:底版是抖动/渐变,重新量化(无论用谁的调色板)都会出噪点或白环。
只有直接改索引数组,底版才保持原样;图案像素按最近色映射到**本机自己的调色板**
(本机图标里本来就有白色,所以白色图案能精确命中)。
"""
w, h, cf = th['w'], th['h'], th['cf']
if cf != ip.CF_I8 or (th['flags'] & 0x08):
return None
if len(tgt_blob) != 12 + 1024 + w * h:
return None
pal_b = tgt_blob[12:12 + 1024]
pal = [(pal_b[i * 4 + 2], pal_b[i * 4 + 1], pal_b[i * 4 + 0], pal_b[i * 4 + 3]) for i in range(256)]
idx = bytearray(tgt_blob[12 + 1024:12 + 1024 + w * h])
# 把图案等比放进 target_bb 后的实际位置/尺寸
nb = ink_bbox(src_img)
if nb is None or target_bb is None:
return None
tw, thh = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
s = min(tw / art.size[0], thh / art.size[1])
art = art.resize((max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))), Image.LANCZOS)
ox = target_bb[0] + (tw - art.width) // 2
oy = target_bb[1] + (thh - art.height) // 2
cache = {}
for y in range(art.height):
for x in range(art.width):
r, g, b, a = art.getpixel((x, y))
if a < 24:
continue
key = (r, g, b, a)
ci = cache.get(key)
if ci is None:
best, bd = 0, 1 << 30
for k in range(256):
pr, pg, pb, pa = pal[k]
d = (pr - r) ** 2 + (pg - g) ** 2 + (pb - b) ** 2 + 2 * (pa - a) ** 2
if d < bd:
bd, best = d, k
cache[key] = ci = best
ix, iy = ox + x, oy + y
if 0 <= ix < w and 0 <= iy < h:
idx[iy * w + ix] = ci
return tgt_blob[:12 + 1024] + bytes(idx)
def plate_share(img, gbb):
"""底版像素占实心像素的比例 —— 用来确认这张图**真的有底版**(不是纯图案)。"""
if gbb is None:
return 0.0
a = img.convert('RGBA').getchannel('A')
w, h = a.size
px = a.load()
ink = tot = 0
for y in range(h):
for x in range(w):
if px[x, y] > 16:
tot += 1
if not (gbb[0] <= x <= gbb[2] and gbb[1] <= y <= gbb[3]):
ink += 1
return (ink / tot) if tot else 0.0
print('\n--- 米家 / 融合中心素材(q63 -> S4 eSIM)%s ---' % ('' if DO_MIJIA else ':已按设置跳过'))
mijia_n = 0
for _q in sorted(s5_all):
if not DO_MIJIA or not _q.startswith(MIJIA_PREFIX):
continue
if _q.endswith('launcher.bin'):
continue # 应用图标由 launcher 组处理(那边处理 ARGB 画法)
_got = s5_all[_q]
if _got[0] is None:
continue
qrom, oino = _got
cands = [_q]
if _q.startswith('fusion_center/icon/'):
cands.append(_q.replace('fusion_center/icon/', 'fusion_center/gauss/'))
for tpath in cands:
tino = s4_all.get(tpath)
if tino is None:
if tpath == _q:
skipped.append((tpath, '本机没有这个槽位'))
continue
tgt_blob = s4.read(tino)
src_blob = qrom.read(oino)
th, sh = img_header(tgt_blob), img_header(src_blob)
if th is None or sh is None:
skipped.append((tpath, '头部异常')); continue
simg = decode_lvgl_bin(src_blob)[1]
if simg is None:
skipped.append((tpath, 'q63 素材解码失败(cf=0x%02x)' % sh['cf'])); continue
how = None
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
new, how = ip.encode_into_slot(simg, tgt_blob, tino.size)
else:
timg = decode_lvgl_bin(tgt_blob)[1]
if timg is None:
skipped.append((tpath, '本机图解码失败(cf=0x%02x)' % th['cf'])); continue
gbb = plate_glyph_bbox(timg)
# 只对「裸图案」的槽位用这个模式(本机是底版+图案、q63 是裸图案)。
# 底版直接用 **q63 那张柔光玻璃盘**(fusion_center/fg.bin),而不是本机原来的平底,
# 这样移植过去的图标看着就是 S5 41mm 的玻璃质感。
if (gbb is not None and ink_ratio(simg) < 0.90
and plate_share(timg, gbb) >= 0.45
and tpath.rsplit('/', 1)[-1][:-4] in PLATE_KEEP):
# 底版原样保留(索引层),只把 q63 的裸图案画进去
new = overlay_glyph_in_place(tgt_blob, th, simg, gbb)
if new is None:
skipped.append((tpath, '索引层合成不可用(非未压缩 I8)')); continue
out, how = None, '底版原样+图案入索引 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
else:
out = normalize_to_bbox(simg, ink_bbox(timg), (th['w'], th['h']))
how = '等比归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
if new is None or len(new) != tino.size:
failed.append((tpath, '编码失败/长度不符 (%s)' % how)); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
name = 'sys_' + tpath.replace('/', '_')[:-4]
new_blobs[name] = new
extra_ino[name] = tino
mijia_paths.add(tpath)
plan.append(dict(name=name, kind='mijia', path=tpath, offset=tino.data_off, length=tino.size,
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
s5_bytes=len(src_blob), s5_from='q63 %s' % _q, method=how))
mijia_n += 1
if mijia_n <= 8 or mijia_n % 20 == 0:
print(' %-52s <- %-34s %s' % (tpath, _q, how))
print(' 米家/融合中心写入 %d 条' % mijia_n)
# ------------------------------------------------- 焦点通知素材(q63 notifications/** -> 本机)
# 同名路径对齐移植;尺寸不同的按实心范围归一化,格式不同自动转换(RLE / ARGB / I8)。
PORT_PREFIXES = ('notifications/', 'phone/', 'recorder/', 'timer/', 'todolist/')
# 用户指定:这两张保持原厂不换(本机槽位形状差太多,压过去效果不好)
NOTIF_SKIP = ('notifications/dotted_line.bin', 'notifications/track.bin',
# 用户指定:录音机这 3 张不换
'recorder/mark_opt.bin', 'recorder/menu.bin', 'recorder/confirm.bin',
# 用户指定:拨号列表这 3 张不换
'phone/icon/list_icon_keypad.bin', 'phone/icon/list_icon_call_log.bin',
'phone/icon/list_icon_contact.bin',
# 用户指定:组件预览底图这 2 张不换
'phone/widget/phone_widget.bin', 'phone/widget/pre_phone_widget.bin')
# 应用图标归「launcher 组」管(那边处理 I8↔ARGB 与留边画法),这里跳过避免重复写同一槽位
PORT_SKIP_TAIL = ('launcher.bin', 'launcher')
print('\n--- 通知/电话/录音机素材(q63 同名移植 -> S4 eSIM)---')
notif_n = 0
for _q in sorted(s5_all):
if not _q.startswith(PORT_PREFIXES):
continue
if _q in NOTIF_SKIP:
skipped.append((_q, '按约定保持原厂')); continue
if _q.rsplit('/', 1)[-1].endswith('launcher.bin'):
continue
_got = s5_all[_q]
if _got[0] is None:
continue
qrom, oino = _got
tino = s4_all.get(_q)
if tino is None:
skipped.append((_q, '本机没有这个槽位')); continue
tgt_blob = s4.read(tino)
src_blob = qrom.read(oino)
th, sh = img_header(tgt_blob), img_header(src_blob)
if th is None or sh is None:
skipped.append((_q, '头部异常')); continue
simg = decode_lvgl_bin(src_blob)[1]
if simg is None:
skipped.append((_q, 'q63 素材解码失败(cf=0x%02x)' % sh['cf'])); continue
how = None
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
new, how = ip.encode_into_slot(simg, tgt_blob, tino.size)
if new is None:
skipped.append((_q, how)); continue
else:
timg = decode_lvgl_bin(tgt_blob)[1]
if timg is None:
skipped.append((_q, '本机图解码失败(cf=0x%02x)' % th['cf'])); continue
# 一律等比缩放(长宽比不同也不拉伸)
out = normalize_to_bbox(simg, ink_bbox(timg), (th['w'], th['h']), fit=True)
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
how = '等比归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
if len(new) != tino.size:
failed.append((_q, '编码 %d != 槽位 %d' % (len(new), tino.size))); continue
if new == tgt_blob:
skipped.append((_q, '两边已经一致')); continue
name = 'sys_' + _q.replace('/', '_')[:-4]
new_blobs[name] = new
extra_ino[name] = tino
mijia_paths.add(_q)
plan.append(dict(name=name, kind='notif', path=_q, offset=tino.data_off, length=tino.size,
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
s5_bytes=len(src_blob), s5_from='q63 %s' % _q, method=how))
notif_n += 1
print(' %-46s <- q63 %-30s %s' % (_q, _q.split('/')[-1], how))
print(' 通知/电话/录音机写入 %d 条' % notif_n)
n_inset = sum(1 for p in plan if p['method'] == 'inset')
print(f'{"name":<24}{"slot":>8}{"fmt":>10}{"S5 bytes":>10} method')
for p in plan:
print(f'{p["name"]:<24}{p["length"]:>8}{p["s4_format"]:>10}{p["s5_bytes"]:>10} {p["method"]}')
for k, why in skipped:
print(f'{k:<24}{"-":>8}{"-":>10}{"-":>10} SKIP {why}')
for k, why in failed:
print(f'{k:<24}{"-":>8}{"-":>10}{"-":>10} FAIL {why}')
print(f'\nreplace {len(plan)} (inset {n_inset}) skip {len(skipped)} failed {len(failed)}')
# 同一槽位可能被多个组写过(sys / 顶栏 / 米家)-> 按 **inode** 去重,后写的赢
_byoff = {}
for _n, _i in extra_ino.items():
_byoff[_i.off] = (_n, _i)
extra_ino = {_n: _i for _n, _i in _byoff.values()}
all_items = [(key, ino) for key, ino in sorted(s4t.items()) if key in new_blobs]
all_items += [(name, ino) for name, ino in extra_ino.items()]
# plan 里同一槽位的旧记录也去掉(只留最后一条)
_dedup = {}
for _p in plan:
_dedup[_p['name']] = _p
plan = list(_dedup.values())
patch, rollback = bytearray(), bytearray()
for key, ino in all_items:
new, old = new_blobs[key], s4.read(ino)
patch += struct.pack('<II', ino.data_off, len(new)) + new
rollback += struct.pack('<II', ino.data_off, len(old)) + old
open(os.path.join(OUTDIR, 'icons.patch'), 'wb').write(patch)
open(os.path.join(OUTDIR, 'icons.rollback'), 'wb').write(rollback)
json.dump(dict(target='Xiaomi Watch S4 eSIM (o62lte) vela_app.bin',
source='Xiaomi Watch S5 (q63) v4.101.020 icons, inset to S4 convention',
sizing=f'full-bleed sources scaled to {144-2*MARGIN}x{144-2*MARGIN} '
f'and centred with a {MARGIN}px transparent margin',
image_sha256=hashlib.sha256(s4.data).hexdigest(),
replace_count=len(plan), records=plan,
skipped=[dict(name=k, reason=w) for k, w in skipped]),
open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
print('\nwriting patched image ...')
shutil.copyfile(S4, PATCHED)
with open(PATCHED, 'r+b') as f:
for key, ino in all_items:
f.seek(ino.data_off)
f.write(new_blobs[key])
r2 = Romfs(PATCHED)
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in s4.entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in r2.entries()]
print(f'entries {len(a)} -> {len(b)} inode table identical: {a == b}')
print(f'superblock identical: {s4.sb_size == r2.sb_size and s4.sb_cksum == r2.sb_cksum}')
i1 = {p: i for p, i in s4.entries()}
i2 = {p: i for p, i in r2.entries()}
changed = sorted(p for p in i1 if s4.read(i1[p]) != r2.read(i2[p]))
diff_items = [(k, i) for k, i in all_items if new_blobs[k] != s4.read(i)]
print(f'changed files ({len(changed)}), 预期 {len(diff_items)}')
assert len(changed) == len(diff_items), 'changed %d != expected %d' % (len(changed), len(diff_items))
assert all(c.rsplit('/', 1)[-1].endswith('launcher.bin') or c in cc_paths or c in walk_paths
or c in mijia_paths for c in changed), changed[:5]
print('sha256', hashlib.sha256(open(PATCHED, "rb").read()).hexdigest())
+58
View File
@@ -0,0 +1,58 @@
"""把 assets/ 里的 UI 素材转成 LVGL v9 二进制图片(.bin),供表盘运行时加载。
格式(已在真机验证):
header 12B: magic=0x19 | cf | flags | w | h | stride | reserved
cf=0x10 ARGB8888 -> 数据为 BGRA,长度 = w*h*4
编码器用 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 = os.path.join(PROJ, 'assets')
OUT = os.path.join(PROJ, 'ui_images')
# 显示尺寸取自 CSS:列表图标 30x30,返回箭头 17x28
SPEC = [
('back', 'backarrow.png', 17, 28),
('ic_help', 'MdiLightbulbOutline.png', 30, 30),
('ic_replace', 'MdiBandage.png', 30, 30),
('ic_about', 'MdiInformationOutline.png', 30, 30),
('ic_check', 'MdiClipboardTextSearchOutline.png', 30, 30),
('ic_rollback', 'MdiReplyAllOutline.png', 30, 30),
('ic_qq', 'qq.png', 30, 30),
]
os.makedirs(OUT, exist_ok=True)
for name, png, w, h in SPEC:
p = os.path.join(ASSETS, png)
if not os.path.exists(p):
print(f' 缺少 {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)
dst = os.path.join(OUT, name + '.bin')
open(dst, 'wb').write(blob)
print(f' {name:<12} <- {png:<34} {w}x{h} {len(blob)} bytes')
# 关于页的 logo:封面图缩到 78x78,并加圆形遮罩
# (封面是圆形图案 + 黑色方角,不裁成圆的话会在墨绿卡片上露出黑方块)
logo_src = os.path.join(ASSETS, 'cover.png')
if os.path.exists(logo_src):
L = 78
img = Image.open(logo_src).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)
blob = ip.encode(img, ip.CF_ARGB8888, L, L, 0)
open(os.path.join(OUT, 'logo.bin'), 'wb').write(blob)
# 顺便存一份 PNG 给本机预览用(注意别放进打包目录,会一起被打进去)
prev = os.path.join(DOCS, 'logo_preview.png')
os.makedirs(os.path.dirname(prev), exist_ok=True)
img.save(prev)
print(f' {"logo":<12} <- cover.png(圆形裁切) {L}x{L} {len(blob)} bytes')
print('written to', OUT)
+180
View File
@@ -0,0 +1,180 @@
"""页面规格 + 本机渲染器:按 CSS 逐条落地,用真 MiSans 字体和真素材渲染预览。
CSS -> 实际能力 的映射(已在真机验证过):
left/top/width/height -> x / y / w / h(466 画布绝对坐标)
background -> bg_color
border-radius -> radius(50x50 圆的 radius=25)
color (alpha 1) -> text_color '#rrggbb'
color (alpha 0.6) -> 白 60% 叠在纯黑底上 = #999999(页面底色是黑,见下)
font-weight 600 -> MiSans-Semibold
font-weight 500 -> MiSans-Medium
vertical-align middle -> vcenter=True
多色文本 -> LVGL recolor: "灰#ffffff 白#灰"
"""
import os
from paths import PROJ, DOCS
ROOT = PROJ
ASSETS = os.path.join(ROOT, 'assets')
W = H = 466
BG = (0, 0, 0) # 封面四角纯黑 -> 页面底色
GRAY = '999999' # rgba(255,255,255,0.6) 叠黑
WHITE = 'ffffff'
PLATE = '002927' # rgba(0,41,39,1)
CIRCLE = '4f4f4f' # rgba(79,79,79,1)
OK = '43cf7c' # rgba(67,207,124,1)
ERR = 'd43030' # rgba(212,48,48,1)
S600, S500 = 'semibold', 'medium'
# 列表项几何(来自第一个表项的 CSS,其余按同一相对关系排布)
ITEM_X, ITEM_W, ITEM_H, ITEM_R = 49, 368, 88, 34
PITCH = 96 # 88 + 8px 间距
ICONC_DX, ICONC_DY, ICONC_SZ = 20, 19, 50 # 圆形遮罩 69-49 / 104-85
ICON_DX, ICON_DY, ICON_SZ = 30, 29, 30 # 图标图片 79-49 / 114-85
TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 80, 17, 208, 54 # 129-49 / 102-85
def item(y, icon, 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)),
dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY,
w=TITLE_W, h=TITLE_H, text=title, size=28, weight=S500,
color=WHITE, align='left', vcenter=True, lh=37.13),
]
def clock(t='10:24'):
return dict(kind='label', x=168, y=0, w=130, h=45, text=t, size=28,
weight=S600, color=GRAY, align='center', vcenter=True, lh=33.6)
def title(text, x=106, w=255):
return dict(kind='label', x=x, y=34, w=w, h=45, text=text, size=30,
weight=S500, color=WHITE, align='center', vcenter=True, lh=44.8)
def back_arrow():
return dict(kind='image', x=160, y=43, w=17, h=28,
src=os.path.join(ASSETS, 'backarrow.png'))
def big_button(y=365, text='确认替换'):
return [
dict(kind='rect', x=108, y=y, w=250, h=80, bg=PLATE, radius=40),
dict(kind='label', x=173, y=y + 13, w=120, h=54, text=text, size=28,
weight=S500, color=WHITE, align='center', vcenter=True, lh=37.13),
]
# ---------------------------------------------------------------- 1. 首页
PAGE_HOME = [
clock(),
title('OS4 Icons'),
*item(85, 'MdiLightbulbOutline.png', '快速帮助'),
*item(181, 'MdiBandage.png', '开始替换'),
*item(277, 'MdiInformationOutline.png', '关于'),
]
# ---------------------------------------------------------------- 2. 快速帮助
_HELP_HEAD = [clock(), back_arrow(), title('快速帮助', x=179, w=128)]
_HELP_I1 = item(85, 'MdiClipboardTextSearchOutline.png', '环境预检查')
_HELP_D1 = dict(kind='label', x=65, y=176, w=338, h=72,
text='检查原始资源状态、分区可写情况等。', size=24, weight=S500,
color=GRAY, align='left', vtop=True, lh=31.82)
_HELP_I2 = item(256, 'MdiReplyAllOutline.png', '回滚修改')
_HELP_D2 = dict(kind='label', x=65, y=344, w=334, h=72,
text='将图标恢复到系统原始样式。', size=24, weight=S500,
color=GRAY, align='left', vtop=True, lh=31.82)
PAGE_HELP = _HELP_HEAD + _HELP_I1 + [_HELP_D1] + _HELP_I2 + [_HELP_D2]
# 帮助页的两种结果状态(同一标签换色换字)
HELP_DESC_OK = dict(kind='label', x=65, y=176, w=338, h=72,
text='预检查通过 | 38 条 | 2.58MB\n您可以返回主页开始修改。',
size=24, weight=S500, color=OK, align='left', vtop=True, lh=31.82)
HELP_DESC_ERR = dict(kind='label', x=65, y=176, w=338, h=72,
text='预检查失败:分区头不是 ROMFS,位置 0x00。',
size=24, weight=S500, color=ERR, align='left', vtop=True, lh=31.82)
PAGE_HELP_OK = _HELP_HEAD + _HELP_I1 + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2]
PAGE_HELP_ERR = _HELP_HEAD + _HELP_I1 + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2]
# ---------------------------------------------------------------- 3. 开始替换
# 多色文本**不能**用 recolor(AP 里那几处是图片重着色,不是文本属性),
# 所以拆成多个标签 + 手工算 x。折行也写死,否则多色片段位置不确定。
# 宽度用真 MiSans 24px 量得:
# "的系统版本号为 " = 177 "3.202.79" = 99
# "Xiaomi Watch S4 eSIM(非" = 313 "15 周年纪念版)" = 178
WARN_LABELS = [
dict(kind='label', x=64, y=90, w=338, h=70,
text='替换图标包之前,请先确认自己\n的系统版本号为 ',
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
dict(kind='label', x=241, y=123, w=104, h=33, text='3.202.79',
size=24, weight=S500, color=WHITE, align='left', vtop=True, lh=33),
dict(kind='label', x=340, y=123, w=30, h=33, text='!',
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
dict(kind='label', x=64, y=189, w=338, h=33, text='请确认自己的手表型号为',
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
dict(kind='label', x=64, y=222, w=338, h=70,
text='Xiaomi Watch S4 eSIM(非\n15 周年纪念版)',
size=24, weight=S500, color=WHITE, align='left', vtop=True, lh=33),
dict(kind='label', x=242, y=255, w=30, h=33, text='!',
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
dict(kind='label', x=64, y=321, w=338, h=33, text='搞机有风险,变砖后果自负。',
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
]
PAGE_CONFIRM = [
clock(),
back_arrow(),
title('开始替换', x=179, w=128),
*WARN_LABELS,
*big_button(365, '确认替换'),
]
# ---------------------------------------------------------------- 4. 替换中
LOG_MID = ('> 开始替换\n'
'> 读取 resource.bin\n'
'> 写入 12/38\n'
'> 写入 38/38 完成,0 失败\n'
'> 替换完毕,请重启手表。若发现问题,请回滚修改。')
PAGE_RUNNING = [
clock(),
title('正在替换', x=179, w=128),
dict(kind='label', x=64, y=90, w=338, h=269, text=LOG_MID, size=24,
weight=S500, color=WHITE, align='left', vtop=True, lh=31.82),
*big_button(365, '重启手表'),
]
# ---------------------------------------------------------------- 5. 关于
PAGE_ABOUT = [
clock(),
dict(kind='image', x=191, y=43, w=17, h=28,
src=os.path.join(ASSETS, 'backarrow.png')),
title('关于', x=212, w=64),
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',
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),
*item(291, 'qq.png', '732681995'),
]
PAGES = {
'home': PAGE_HOME,
'help': PAGE_HELP,
'help_ok': PAGE_HELP_OK,
'help_err': PAGE_HELP_ERR,
'confirm': PAGE_CONFIRM,
'about': PAGE_ABOUT,
'running': PAGE_RUNNING,
}
+35
View File
@@ -0,0 +1,35 @@
"""项目里所有脚本的路径入口 —— 换机器 / 换目录只看这一个文件。
默认布局(都相对项目根):
inputs/target/ 目标固件的分区镜像(要自己放,见 README)
inputs/source/ 图标来源的分区镜像(要自己放)
inputs/fonts/ 可选:MiSans-Medium.ttf / MiSans-Semibold.ttf(渲染预览用)
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')
for _d in (WORK, DIST, DOCS, INPUT_TARGET, INPUT_SOURCE):
os.makedirs(_d, exist_ok=True)
+193
View File
@@ -0,0 +1,193 @@
"""按页面规格渲染 466x466 预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。
支持 kind: rect / circle / image / label
label 支持 recolor 标记 "#RRGGBB 文字#"(LVGL 的写法),以及 align/vcenter/vtop/lh
"""
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 = 466
_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):
"""LVGL 写法: '灰#ffffff 白#灰'
- # + 6 位十六进制 -> 切到该颜色(该字符不显示)
- 再次遇到单独的 # -> 结束着色,回到基础色(该字符不显示)
"""
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):
"""按像素宽度折行,返回 [[(color, text), ...], ...](尽量保持原换行)"""
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 = [] # (标签文本前 12 字, css_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) # LVGL 用字体自身行高,取较大者预测
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(items, out, mask=True, ring=False):
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)
(d.rounded_rectangle if r else d.rectangle)(
box, radius=r, fill=_rgb(it['bg']) + (255,)) if r 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)
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))
base.save(out)
return out
if __name__ == '__main__':
from pages import PAGES
outs = []
for name, items in PAGES.items():
p = os.path.join(ROOT, '_out', f'ui_{name}.png')
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):
im = Image.open(p)
sheet.paste(im, (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12)))
sp = os.path.join(ROOT, '_out', 'ui_all.png')
sheet.save(sp)
print('wrote', sp, sheet.size)
+112
View File
@@ -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}')
+175
View File
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""S4 图标表盘交付物的独立校验(不信任构建脚本的账本)。
从 dist/S5Icons.face 里把 Lua 源码抠出来 -> 解析 UIIMG / PAYTBL 两张表 ->
每一条都拿去和 **固件镜像**(inputs/target 原厂 + .work/s4_from_s5 改后)以及
ui_images/ 目录逐字节比对;另外单独验证 voice_aivs 那条的字节就是
assets/override/voice_aivs.png 按槽位格式重编码的结果。
用法: python tools/s4e_verify.py
"""
import os, re, sys, json, hashlib
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from romfs import Romfs
from lvgl_bin import decode_lvgl_bin
import iconpack as ip
from PIL import Image, ImageFilter
from paths import PROJ, INPUTS, WORK, DIST
FACE = os.path.join(DIST, 'S5Icons.face')
STOCK = os.path.join(INPUTS, 'target', 'vela_app.bin')
PATCHED = os.path.join(WORK, 's4_from_s5', 'vela_app_s5icons.bin')
MANIFEST = os.path.join(WORK, 's4_from_s5', 'manifest.json')
WID = '362150101'
fails = []
def ck(cond, msg):
print((' OK ' if cond else ' FAIL ') + msg)
if not cond:
fails.append(msg)
face = open(FACE, 'rb').read()
_end = face.find(b'return ui\n')
if _end < 0:
raise SystemExit('face 里找不到 Lua 结尾')
_start = face.rfind(b'--[[', 0, _end)
lua = face[_start:_end + len(b'return ui\n')]
if not lua.startswith(b'--[[--') or b'local UIIMG = {' not in lua:
raise SystemExit('Lua 提取失败:%r ... %r' % (lua[:24], lua[-24:]))
txt = lua.decode('utf-8')
print('== 头部 ==')
ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12')
ck(face[40:50] == (WID + '\x00').encode(), 'id = %r' % face[40:50])
ck(face[5] == 0, 'b5 = %d(固件自带表盘也是 0)' % face[5])
ck(face.count(lua) == 1, 'Lua 源码原样出现 %d 次(%d B)' % (face.count(lua), len(lua)))
print(' 文件 %d B' % len(face))
print('== UI 图片 ==')
ui = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt)
files = {f[:-4]: open(os.path.join(PROJ, 'ui_images', f), 'rb').read()
for f in os.listdir(os.path.join(PROJ, 'ui_images')) if f.endswith('.bin')}
ck(len(ui) == len(files), 'UIIMG %d 条(ui_images 里 %d 个)' % (len(ui), len(files)))
for o, l, n in ui:
o, l = int(o), int(l)
ck(n in files and face[o:o + l] == files[n],
'%-12s @0x%06x len=%-6d 与 ui_images/%s.bin 一致' % (n, o, l, n))
print('== 负载表结构 ==')
_pay_at = txt.index('pay = {')
_rb_at = txt.index('rb = {', _pay_at)
pay = [(int(a), int(b), int(c)) for a, b, c in re.findall(r'\{(\d+),(\d+),(\d+)\}', txt[_pay_at:_rb_at])]
rb = [(int(a), int(b), int(c)) for a, b, c in re.findall(r'\{(\d+),(\d+),(\d+)\}', txt[_rb_at:])]
exp = json.load(open(MANIFEST, encoding='utf-8'))['records'] # 计划替换的 81 条
pj = json.load(open(os.path.join(PROJ, 'payload.json'), encoding='utf-8'))
emit = pj['records'] # 实际下发的(含 canary)
dropped = pj['dropped_identical']
ck(len(emit) - 1 == len(exp) - len(dropped),
'下发 %d 条 = 计划 %d 条 - 与源逐字节相同的 %d 条 %s + canary'
% (len(emit), len(exp), len(dropped), dropped))
ck(len(pay) == len(rb) == len(emit), 'pay %d 条 / rb %d 条(payload.json %d 条)'
% (len(pay), len(rb), len(emit)))
ck(len(pay) > 0 and len(pay) == len(rb), '两张表条数相同')
print('== 逐条字节比对(负载 vs 改后镜像、回滚 vs 原厂镜像)==')
stock = open(STOCK, 'rb').read()
patched = open(PATCHED, 'rb').read()
ck(len(stock) == len(patched) and stock[:8] == b'-rom1fs-', '两个整分区镜像等长且是 ROMFS')
bad, misalign, manifest_bad = 0, 0, 0
for i, ((po, pt, pl), (ro, rt, rl)) in enumerate(zip(pay, rb)):
if not (po and ro) or (pt, pl) != (rt, rl):
manifest_bad += 1
continue
if pt % 4 or pt + pl > len(stock): # RLE 素材的长度可以是任意值,只有偏移必须对齐
misalign += 1
if (face[po:po + pl] != patched[pt:pt + pl] or len(face[po:po + pl]) != pl
or face[ro:ro + pl] != stock[pt:pt + pl] or len(face[ro:ro + pl]) != pl):
bad += 1
print(' FAIL #%d %s off=0x%x len=%d'
% (i, emit[i]['name'] if i < len(emit) else '?', pt, pl))
ck(bad == 0, '%d 条:负载 == 改后镜像,回滚 == 原厂镜像' % len(pay))
ck(misalign == 0, '全部 4 对齐且不越界')
ck(manifest_bad == 0, 'pay/rb 的目标偏移与长度一一对应')
names_in_lua = [e['name'] for e in emit]
plan_only = [r['name'] for r in exp if r['name'] not in dropped]
ck(names_in_lua[1:] == plan_only, '下发名单(去掉 canary)== 计划名单(去掉 %s)' % dropped)
print('== 负载名单 ==')
names = [r['name'] for r in exp]
ck('voice_aivs' in names, 'voice_aivs 在负载里')
ck(sorted(names) == sorted(set(names)), '没有重名记录')
print('== voice_aivs 的字节 == 用户 PNG 重编码 ==')
rec = next(r for r in exp if r['name'] == 'voice_aivs')
r = Romfs(STOCK)
sheet = {p: i for p, i in r.entries() if p == 'voice/aivs/launcher.bin'}
ino = sheet['voice/aivs/launcher.bin']
ck(ino.data_off == rec['offset'] and ino.size == rec['length'],
'manifest 偏移/长度 == ROMFS inode(0x%08x / %d)' % (ino.data_off, ino.size))
h, stock_img = decode_lvgl_bin(r.read(ino))
def ink_bbox(img, thr=16):
a = img.convert('RGBA').getchannel('A'); w, hh = a.size; px = a.load()
x0, y0, x1, y1 = w, hh, -1, -1
for y in range(hh):
for x in range(w):
if px[x, y] > thr:
x0 = min(x0, x); y0 = min(y0, y); x1 = max(x1, x); y1 = max(y1, y)
return None if x1 < 0 else (x0, y0, x1, y1)
sp = os.path.join(PROJ, 'assets', 'override', 'voice_aivs.png')
src = Image.open(sp).convert('RGBA')
sb = ink_bbox(stock_img)
target = max(sb[2] - sb[0] + 1, sb[3] - sb[1] + 1)
nb = ink_bbox(src)
art = src.crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1)).resize((target, target), Image.LANCZOS)
cv = Image.new('RGBA', (h['w'], h['h']), (0, 0, 0, 0))
cv.paste(art, ((h['w'] - target) // 2, (h['h'] - target) // 2))
want = ip.encode(cv, h['cf'], h['w'], h['h'], h['flags'])
ck(len(want) == ino.size, '按槽位格式重编码 %d B == 槽位 %d B' % (len(want), ino.size))
got = [(o, t, l) for (o, t, l) in pay if t == rec['offset']]
ck(bool(got) and face[got[0][0]:got[0][0] + rec['length']] == want,
'交付物里 voice_aivs 的负载字节 == 用户 PNG 重编码结果')
ck(patched[ino.data_off:ino.data_off + ino.size] == want, '改后整分区镜像里也是同一份字节')
ck(stock[ino.data_off:ino.data_off + ino.size] != want, '与 S4 原厂字节确实不同')
ck(os.path.basename(sp) == 'voice_aivs.png' and
hashlib.sha256(open(sp, 'rb').read()).hexdigest()[:16] == '362166b54ac03f2b',
'override 源图 sha256 362166b54ac03f2b(= 用户给的 smallai.png)')
print('== ROMFS 结构未被破坏 ==')
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(STOCK).entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(PATCHED).entries()]
ck(a == b, 'inode 表逐位不变(%d 条)' % len(a))
ck(Romfs(STOCK).sb_size == Romfs(PATCHED).sb_size and
Romfs(STOCK).sb_cksum == Romfs(PATCHED).sb_cksum, 'superblock 不变')
r2 = Romfs(PATCHED)
i1 = {p: i for p, i in Romfs(STOCK).entries()}
i2 = {p: i for p, i in r2.entries()}
changed = sorted(p for p in i1 if Romfs(STOCK).read(i1[p]) != r2.read(i2[p]))
ck(len(changed) == len(pay) - 1, '真正变化的文件 %d 个(负载 %d 条,其中 canary 是分区头)'
% (len(changed), len(pay)))
print('== 负载/回滚文件在 .face 里连续存放 ==')
pfiles = [(f, open(os.path.join(PROJ, 'payload', f), 'rb').read())
for f in sorted(os.listdir(os.path.join(PROJ, 'payload'))) if f.endswith('.bin')]
rbfiles = [(f, open(os.path.join(PROJ, 'payload_rollback', f), 'rb').read())
for f in sorted(os.listdir(os.path.join(PROJ, 'payload_rollback'))) if f.endswith('.bin')]
ck(len(pfiles) == len(pay), 'payload/ 里 %d 个文件 == %d 条' % (len(pfiles), len(pay)))
hit_pay = sum(1 for (o, t, l) in pay if any(face[o:o + l] == d for _, d in pfiles))
hit_rb = sum(1 for (o, t, l) in rb if any(face[o:o + l] == d for _, d in rbfiles))
ck(hit_pay == len(pay), '每条负载都能在 payload/ 里找到同名同字节的文件(%d/%d)' % (hit_pay, len(pay)))
ck(hit_rb == len(rb), '每条回滚都能在 payload_rollback/ 里找到(%d/%d)' % (hit_rb, len(rb)))
print()
if fails:
print('!! %d 项未通过' % len(fails))
sys.exit(1)
print('全部通过 —— %s (%d B, sha256 %s)' % (os.path.basename(FACE), len(face),
hashlib.sha256(face).hexdigest()[:16]))