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
+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__)
+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
+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}')
+266
View File
@@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
"""Two-pass build for the S5 eSIM icon face.
watchface_s5e/main.lua 源码(含 __UIIMG__ / __PAY__ / __RB__ 占位符)
build_overlay_s5e/S5eIcons 中间产物(回填后的 Lua 落在这里)
_build_s5e/S5eIcons 编译工作区(Compiler.exe 的输入)
_out/faces/S5eIcons.face 交付物
为什么两遍:.face 里各资源的偏移取决于打包顺序,我们靠在 .face 里搜字节来定位。
Lua 源码排在所有资源之后,所以回填(Lua 变长)不会移动前面的资源;脚本最后逐条校验 0 漂移。
"""
import os, sys, json, shutil, subprocess, hashlib, struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PIL import Image, ImageDraw
import render_ui as R
from s5e_pages import PAGES
from paths import PROJ, WORK, DIST, TEMPLATE
WF = PROJ # 源码就在项目根
TPL = TEMPLATE
OVER = os.path.join(WORK, 'overlay', 'S5eIcons')
BUILD = os.path.join(WORK, 'build', 'S5eIcons')
OUTFACE = DIST
NAME = 'S5eIcons'
WF_ID = '462150101' # 462 = S5 家族(固件自带 AOD 表盘是 462150001..8)
DEVICE_TYPE = '462'
SCREEN = 480
PREVIEW = 326
MAGIC_PAY = b'\x00S5E-PAYv1\x00\xa5\x5a\xc3\x3c'
MAGIC_RB = b'\x00S5E-RBKv1\x00\x5a\xa5\x3c\xc3'
MAGIC_LEN = len(MAGIC_PAY) # 15 —— 别再手写常量,之前写 16 让偏移整体差 1
LOG = []
def log(s):
print(s)
LOG.append(s)
def make_preview(path):
"""480 首页 -> 圆形 alpha -> 326x326(Compiler.exe 只收这个尺寸)"""
R.W = R.H = SCREEN
items = PAGES['home']
img = Image.new('RGBA', (SCREEN, SCREEN), (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(box, radius=r, fill=R._rgb(it['bg']) + (255,)) if r
else d.rectangle(box, fill=R._rgb(it['bg']) + (255,)))
elif k == 'circle':
d.ellipse([it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1],
fill=R._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':
R.draw_label(img, d, it)
base = Image.new('RGBA', (SCREEN, SCREEN), (0, 0, 0, 255))
base.alpha_composite(img)
mask = Image.new('L', (SCREEN, SCREEN), 0)
ImageDraw.Draw(mask).ellipse([0, 0, SCREEN - 1, SCREEN - 1], fill=255)
base.putalpha(mask)
base.resize((PREVIEW, PREVIEW), Image.LANCZOS).save(path)
def prepare(lua_text):
if os.path.exists(BUILD):
shutil.rmtree(BUILD)
shutil.copytree(TPL, BUILD)
old = os.path.join(BUILD, 'watchface', 'fprj', 'LuaDevTemplate.fprj')
if os.path.exists(old):
os.remove(old)
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(BUILD, 'watchface.config.json'), 'w', encoding='utf-8'),
indent=4, ensure_ascii=False)
fprj = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
'<FaceProject DeviceType="%s">\r\n'
' <Screen Title="%s" Bitmap="preview.png">\r\n'
' <Widget Shape="34" Name="app_lua%%2Fmain.lua" X="0" Y="0" '
'Width="%d" Height="%d" Alpha="0" />\r\n'
' </Screen>\r\n</FaceProject>\r\n') % (DEVICE_TYPE, NAME, SCREEN, SCREEN)
open(os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj'), 'w',
encoding='utf-16', newline='').write(fprj)
fprjdir = os.path.join(BUILD, 'watchface', 'fprj')
make_preview(os.path.join(fprjdir, 'images', 'preview.png'))
open(os.path.join(fprjdir, 'app', 'lua', 'main.lua'), 'w',
encoding='utf-8', newline='\n').write(lua_text)
imgdir = os.path.join(fprjdir, 'app', 'images')
for sub, src in (('pay', os.path.join(WF, 'payload')),
('ui', os.path.join(WF, 'ui_images'))):
dst = os.path.join(imgdir, sub)
if os.path.exists(dst):
shutil.rmtree(dst)
os.makedirs(dst)
for fn in sorted(os.listdir(src)):
shutil.copyfile(os.path.join(src, fn), os.path.join(dst, fn))
shutil.copyfile(os.path.join(WF, 'payload_rollback', 'rollback.bin'),
os.path.join(imgdir, 'pay', 'rollback.bin'))
def run_build(script=True):
ps = os.path.join(BUILD, 'scripts', 'build_face.ps1')
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
capture_output=True, text=True, errors='replace')
tail = [l for l in ((r.stdout or '') + (r.stderr or '')).splitlines() if l.strip()][-6:]
for l in tail:
log(' | ' + l)
face = os.path.join(BUILD, 'bin', NAME + '.face')
if not os.path.exists(face):
raise SystemExit('build failed: no ' + face)
return open(face, 'rb').read()
def compile_raw():
exe = os.path.join(BUILD, 'watchface', 'tools', 'Compiler.exe')
fprj = os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj')
out = os.path.join(BUILD, 'raw')
if os.path.exists(out):
shutil.rmtree(out)
os.makedirs(out)
subprocess.run([exe, '-b', fprj, out, NAME + '.face', WF_ID],
capture_output=True, text=True, errors='replace')
p = os.path.join(out, NAME + '.face')
if not os.path.exists(p):
raise SystemExit('raw compile failed')
return open(p, 'rb').read()
def hits(face, blob):
out, s = [], 0
while True:
i = face.find(blob, s)
if i < 0:
return out
out.append(i)
s = i + 1
def locate(face, rels):
"""量出 UI 图片 / 负载 在 .face 里的偏移,并逐条字节校验"""
ui = []
for fn in sorted(os.listdir(os.path.join(WF, 'ui_images'))):
if not fn.endswith('.bin'):
continue
blob = open(os.path.join(WF, 'ui_images', fn), 'rb').read()
h = hits(face, blob)
if len(h) != 1:
raise SystemExit('UI %s 命中 %d 次' % (fn, len(h)))
ui.append((h[0], len(blob), fn[:-4]))
pay_base = hits(face, MAGIC_PAY)
rb_base = hits(face, MAGIC_RB)
if len(pay_base) != 1 or len(rb_base) != 1:
raise SystemExit('payload magic 命中 %d/%d 次(应为 1)' % (len(pay_base), len(rb_base)))
pay_base, rb_base = pay_base[0], rb_base[0]
hashes = {'pay': {i: r['sha256'] for i, r in enumerate(recs)},
'rb': {i: r['rb_sha256'] for i, r in enumerate(recs)}}
for tag, base, path in (('pay', pay_base, os.path.join(WF, 'payload', 'payload.bin')),
('rb', rb_base, os.path.join(WF, 'payload_rollback', 'rollback.bin'))):
data = open(path, 'rb').read()
# 整个负载文件必须**原样连续**放在 base 处 —— 这是"偏移=base+magic+rel"成立的前提
if face.find(data) != base:
raise SystemExit('%s 文件不是原样连续存放(找不到或位置不符)' % tag)
for i, (off, ln) in enumerate(rels[tag]):
res = base + MAGIC_LEN + off
if i == 0:
log(' %s 资源偏移 0x%x(%%4=%d,运行时走 /tmp 中转,不依赖对齐)'
% (tag, res, res % 4))
# 用独立的 sha256(来自生成负载时的 manifest)校验,避免"自己比自己"这种恒真检查
seg = face[res:res + ln]
if len(seg) != ln or hashlib.sha256(seg).hexdigest() != hashes[tag][i]:
raise SystemExit('%s[%d] 在 0x%x 处字节不吻合' % (tag, i, res))
return ui, pay_base, rb_base
FACE = None
rels = {'pay': [], 'rb': []}
recs = json.load(open(os.path.join(WORK, 's5e_from_os4', 'manifest.json'), encoding='utf-8'))['records']
off = 0
for r in recs:
rels['pay'].append((off, r['length']))
off += r['length']
off = 0
for r in recs:
rels['rb'].append((off, r['length']))
off += r['length']
src = open(os.path.join(WF, 'main.lua'), encoding='utf-8').read()
for ph in ('__UIIMG__', '__PAY__', '__RB__'):
if ph not in src:
raise SystemExit('watchface_s5e/main.lua 缺少占位符 ' + ph)
# ---------------------------------------------------------------- pass 1
log('第 1 遍:占位符版编一次,量出资源偏移')
prepare(src)
face1 = run_build()
log(' face %d bytes' % len(face1))
ui, pay_base, rb_base = locate(face1, rels)
for o, l, n in ui:
log(' %-12s off=0x%06x len=%d' % (n, o, l))
log(' payload base=0x%06x rollback base=0x%06x' % (pay_base, rb_base))
ui_txt = '\n'.join(' { %d, %d, "%s" },' % (o, l, n) for o, l, n in ui)
pay_txt = ' pay = {\n' + '\n'.join(
' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], pay_base + MAGIC_LEN + rels['pay'][i][0])
for i, r in enumerate(recs)) + '\n },'
rb_txt = ' rb = {\n' + '\n'.join(
' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], rb_base + MAGIC_LEN + rels['rb'][i][0])
for i, r in enumerate(recs)) + '\n },'
resolved = src.replace('__UIIMG__', ui_txt).replace('__PAY__', pay_txt).replace('__RB__', rb_txt)
assert '__UIIMG__' not in resolved and '__PAY__' not in resolved and '__RB__' not in resolved
overlay_lua = os.path.join(OVER, 'watchface', 'fprj', 'app', 'lua')
os.makedirs(overlay_lua, exist_ok=True)
open(os.path.join(overlay_lua, 'main.lua'), 'w', encoding='utf-8', newline='\n').write(resolved)
log('第 2 遍:回填后的 Lua(%d bytes)重新编译' % len(resolved))
# ---------------------------------------------------------------- pass 2
prepare(resolved)
face2 = run_build()
ui2, pay_base2, rb_base2 = locate(face2, rels)
drift = 0
if (pay_base2, rb_base2) != (pay_base, rb_base) or ui2 != ui:
drift = 1
log('偏移校验:%s' % ('0 漂移' if not drift else '!! 有漂移'))
if drift:
raise SystemExit('resource offsets drifted between pass 1 and pass 2')
# ---------------------------------------------------------------- finalize
raw = compile_raw()
d = bytearray(raw)
assert d[0:4] == b'\x5a\xa5\x34\x12', 'bad face magic'
d[40:50] = WF_ID.encode() + b'\x00' * (10 - len(WF_ID))
os.makedirs(OUTFACE, exist_ok=True)
faithful = os.path.join(OUTFACE, NAME + '.face')
open(faithful, 'wb').write(bytes(d))
toolchain = os.path.join(OUTFACE, NAME + '_toolchain.face')
shutil.copyfile(os.path.join(BUILD, 'bin', NAME + '.face'), toolchain)
# 交付产物独立自查:直接从最终 .face 里抠出来比对
fin = open(faithful, 'rb').read()
ui3, pb3, rb3 = locate(fin, rels)
assert ui3 == ui and pb3 == pay_base and rb3 == rb_base
for i, r in enumerate(recs):
res = pb3 + MAGIC_LEN + rels['pay'][i][0]
assert fin[res:res + r['length']] == open(os.path.join(WF, 'payload', 'payload.bin'), 'rb').read()[MAGIC_LEN + rels['pay'][i][0]:MAGIC_LEN + rels['pay'][i][0] + r['length']]
log('')
log('%-34s %10s b4 b5 id sha256[:16]' % ('file', 'bytes'))
for p in (faithful, toolchain):
b = open(p, 'rb').read()
log('%-34s %10d %2d %2d %-10r %s' % (os.path.basename(p), len(b), b[4], b[5], b[40:50],
hashlib.sha256(b).hexdigest()[:16]))
_p32, = struct.unpack('<I', fin[32:36])
_pal, _pw, _ph, _psz = struct.unpack('<IHHI', fin[_p32:_p32 + 12])
log('preview @0x%x: %dx%d, palette %d B, data %d B' % (_p32, _pw, _ph, _pal, _psz))
log('交付物:' + faithful)
open(os.path.join(WORK, 's5e_build_log.txt'), 'w', encoding='utf-8').write('\n'.join(LOG))
+52
View File
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""Round-screen clipping check for the 480 spec (measures real rendered pixels)."""
import os, sys, math
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import render_ui as R
from PIL import Image, ImageDraw
W = H = 480
CX = CY = 240.0
RAD = 240.0
from s5e_pages import PAGES
def render_raw(items):
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(box, radius=r, fill=R._rgb(it['bg']) + (255,)) if r
else d.rectangle(box, fill=R._rgb(it['bg']) + (255,)))
elif k == 'circle':
d.ellipse([it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1],
fill=R._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':
R.draw_label(img, d, it)
return img
print('%-10s %8s %11s worst point' % ('page', 'max r', 'outside px'))
print('-' * 60)
bad = 0
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:
dd = math.hypot(x - CX, y - CY)
if dd > worst:
worst, wpt = dd, (x, y)
if dd > RAD:
outside += 1
print('%-10s %8.1f %11d %s%s' % (name, worst, outside, wpt,
'' if worst <= RAD else ' <-- CLIPPED'))
if outside: bad += 1
print('-' * 60)
print('centre (240,240) radius 240 canvas 480x480 pages clipped:', bad)
+133
View File
@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""Xiaomi Watch S5 eSIM (480x480) page spec.
Same design language as the 466x466 S5->S4 face, uniformly scaled by 480/466 and
re-centred; every coordinate here is what watchface_s5e/main.lua uses, and
_tools/s5e_render_ui.py draws exactly this spec so the previews are honest.
"""
import os
from paths import PROJ, DOCS
ROOT = PROJ
ASSETS = os.path.join(ROOT, 'assets')
W = H = 480
BG = (0, 0, 0)
GRAY = '999999'
WHITE = 'ffffff'
PLATE = '002927'
CIRCLE = '4f4f4f'
OK = '43cf7c'
ERR = 'd43030'
S600, S500 = 'semibold', 'medium'
# list item geometry
ITEM_X, ITEM_W, ITEM_H, ITEM_R = 50, 379, 91, 35
ICONC_DX, ICONC_DY, ICONC_SZ = 21, 20, 52
ICON_DX, ICON_DY, ICON_SZ = 31, 30, 31
TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 82, 26, 214, 40
ICON_PNG = {'ic_help': 'MdiLightbulbOutline.png', 'ic_replace': 'MdiBandage.png',
'ic_about': 'MdiInformationOutline.png', 'ic_check': 'MdiClipboardTextSearchOutline.png',
'ic_rollback': 'MdiReplyAllOutline.png', 'ic_qq': 'qq.png'}
def item(y, iconkey, title):
return [
dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R),
dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY, w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE),
dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY, w=ICON_SZ, h=ICON_SZ,
src=os.path.join(ASSETS, ICON_PNG[iconkey])),
dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY, w=TITLE_W, h=TITLE_H,
text=title, size=29, weight=S500, color=WHITE, align='left', vtop=True),
]
def clock(t='10:24'):
return dict(kind='label', x=201, y=4, w=78, h=46, text=t, size=29,
weight=S600, color=GRAY, align='left', vtop=True)
def title(text, x, w=260):
return dict(kind='label', x=x, y=36, w=w, h=46, text=text, size=31,
weight=S500, color=WHITE, align='left', vtop=True)
def back_arrow(x=167):
return dict(kind='image', x=x, y=44, w=18, h=29, src=os.path.join(ASSETS, 'backarrow.png'))
def big_button(y, text):
return [
dict(kind='rect', x=111, y=y, w=258, h=82, bg=PLATE, radius=41),
dict(kind='label', x=182, y=y + 22, w=126, h=40, text=text, size=29,
weight=S500, color=WHITE, align='left', vtop=True),
]
PAGE_HOME = [clock(), title('OS4 Icons', 164, 152),
*item(88, 'ic_help', '快速帮助'),
*item(186, 'ic_replace', '开始替换'),
*item(285, 'ic_about', '关于')]
_HELP_HEAD = [clock(), back_arrow(167), title('快速帮助', 188, 124)]
_HELP_D1 = dict(kind='label', x=67, y=182, w=348, h=76,
text='检查 app / health 两个分区\n与 47 条负载的完整性。', size=25,
weight=S500, color=GRAY, align='left', vtop=True)
_HELP_I2 = item(264, 'ic_rollback', '回滚修改')
_HELP_D2 = dict(kind='label', x=67, y=355, w=348, h=36,
text='恢复 47 个图标的原始样式。', size=25,
weight=S500, color=GRAY, align='left', vtop=True)
PAGE_HELP = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [_HELP_D1] + _HELP_I2 + [_HELP_D2]
HELP_DESC_OK = dict(kind='label', x=67, y=182, w=348, h=76,
text='预检查通过 | 47 条 | 3.72MB\n两个分区都可读。',
size=25, weight=S500, color=OK, align='left', vtop=True)
HELP_DESC_ERR = dict(kind='label', x=67, y=182, w=348, h=76,
text='预检查失败:设备节点不可读\n或分区头不是 ROMFS。',
size=25, weight=S500, color=ERR, align='left', vtop=True)
PAGE_HELP_OK = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2]
PAGE_HELP_ERR = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2]
WARN_LABELS = [
dict(kind='label', x=60, y=93, w=360, h=36,
text='替换图标包之前,请先确认自己', size=25, weight=S500, color=GRAY,
align='left', vtop=True),
dict(kind='label', x=60, y=126, w=360, h=36,
text='的系统版本号为 ', size=25, weight=S500, color=GRAY, align='left', vtop=True),
dict(kind='label', x=244, y=126, w=120, h=36, text='3.112.035',
size=25, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=356, y=126, w=40, h=36, text='!',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
dict(kind='label', x=60, y=196, w=360, h=36, text='请确认自己的手表型号为',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
dict(kind='label', x=60, y=229, w=372, h=36, text='Xiaomi Watch S5 eSIM 46mm',
size=25, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=427, y=229, w=40, h=36, text='!',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
dict(kind='label', x=60, y=331, w=360, h=36, text='搞机有风险,变砖后果自负。',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
]
PAGE_CONFIRM = [clock(), back_arrow(167), title('开始替换', 188, 124)] + WARN_LABELS + big_button(376, '确认替换')
LOG_MID = ('> 开始替换\n'
'> app 33 条 / health 14 条\n'
'> 写入 20/47\n'
'> 替换完毕,请重启手表。\n'
'> 若发现问题,请回滚修改。')
PAGE_RUNNING = [clock(), title('正在替换', 178, 124),
dict(kind='label', x=67, y=93, w=348, h=277, text=LOG_MID, size=25,
weight=S500, color=WHITE, align='left', vtop=True),
*big_button(376, '重启手表')]
PAGE_ABOUT = [
clock(), back_arrow(198), title('关于', 220, 62),
dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31),
dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')),
dict(kind='label', x=170, y=198, w=141, h=40, text='OS4 Icons',
size=29, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=216, y=234, w=49, h=28, text='1.0.0',
size=21, weight=S500, color=GRAY, align='left', vtop=True),
*item(300, 'ic_qq', '732681995'),
]
PAGES = {'home': PAGE_HOME, 'help': PAGE_HELP, 'help_ok': PAGE_HELP_OK, 'help_err': PAGE_HELP_ERR,
'confirm': PAGE_CONFIRM, 'running': PAGE_RUNNING, 'about': PAGE_ABOUT}
+808
View File
@@ -0,0 +1,808 @@
# -*- coding: utf-8 -*-
"""S5 41mm (q63, OS4) launcher icons -> Xiaomi Watch S5 eSIM (p62lte, 480x480).
Target slots live in TWO partitions:
vela_app.bin (/dev/app) 34 launcher.bin
vela_health.bin (/dev/health) 15 launcher.bin
All 49 target slots are 144x144 ARGB8888 (82956 bytes) -- so the replacement is
ALWAYS exactly one slot long, which is what keeps the ROMFS absolute offsets valid.
Sizing convention (measured, not guessed):
the S5 eSIM stock art is inset in the 144 canvas (136x136 at (4,4) for almost
every slot, 138 at (3,3) for launcher_*, 144 full-bleed for a few), while the
OS4 source art is mostly full-bleed 144x144. So each source is cropped to its
painted bbox, scaled to the TARGET slot's own painted size and pasted at the
TARGET slot's origin. Sources that already match are copied without resampling
(bit-exact).
Outputs
watchface_s5e/payload/payload.bin MAGIC + concat(replace blobs)
watchface_s5e/payload/payload.lua per-record table for main.lua
watchface_s5e/payload_rollback/... same for the stock bytes
_out/s5e_from_os4/vela_app_os4.bin whole-image variants (route C)
_out/s5e_from_os4/vela_health_os4.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 decode_lvgl_bin
import iconpack as ip
from PIL import Image, ImageFilter
from paths import PROJ, INPUTS, WORK
NEW = os.path.join(INPUTS, 'target') # 目标固件分区(p62lte)
OLD = os.path.join(INPUTS, 'source') # 图标来源分区(q63 = S5 41mm)
WF = PROJ
OUT = os.path.join(WORK, 's5e_from_os4') # manifest + 整分区镜像(产物)
PART = [('app', 'vela_app.bin', '/dev/app'),
('health', 'vela_health.bin', '/dev/health')]
MAGIC_PAY = b'\x00S5E-PAYv1\x00\xa5\x5a\xc3\x3c' # 15 B, must stay unique in the .face
MAGIC_RB = b'\x00S5E-RBKv1\x00\x5a\xa5\x3c\xc3'
# 自制图覆盖:优先级高于固件来源(和 S4 项目同一套做法)。
# activities : 112x112 的红/绿/青同心环 = 用户要的「跑道」图标
# voice_aivs : 144x144 的「小爱同学」用户 PNG
OVERRIDE = {k: os.path.join(PROJ, 'assets', 'override', k + '.png')
for k in ('activities', 'voice_aivs')}
os.makedirs(OUT, exist_ok=True)
os.makedirs(os.path.join(WF, 'payload'), exist_ok=True)
os.makedirs(os.path.join(WF, 'payload_rollback'), exist_ok=True)
def ink_bbox(img, thr=16):
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
return None if x1 < 0 else (x0, y0, x1, y1)
def normalize(src_img, slot_bb, canvas):
"""crop to painted bbox -> scale to the slot's painted size -> paste at slot origin"""
cw, ch = canvas
if slot_bb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
sx0, sy0, sx1, sy1 = slot_bb
tw, th = sx1 - sx0 + 1, sy1 - sy0 + 1
nb = ink_bbox(src_img)
if nb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
if (nb[2] - nb[0] + 1, nb[3] - nb[1] + 1) == (tw, th) and (nb[0], nb[1]) == (sx0, sy0):
return src_img.convert('RGBA') # already identical geometry
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
if art.size != (tw, th):
up = tw > art.size[0] or th > art.size[1]
art = art.resize((tw, th), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
canvas_img = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
canvas_img.paste(art, (sx0, sy0))
return canvas_img
def normalize_fit(src_img, target_bb, canvas):
"""等比缩放源实心区域到能放进 target_bb 的最大尺寸并居中(不拉伸)。"""
cw, ch = canvas
cv = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
if target_bb is None:
return cv
nb = ink_bbox(src_img)
if nb is None:
return cv
tw, th = 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], th / 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))
cv.paste(art, (target_bb[0] + (tw - nw) // 2, target_bb[1] + (th - nh) // 2))
return cv
# ------------------------------------------------------------------ load slots
slots = [] # ordered records
ROM_N = {}
for tag, fn, dev in PART:
r = Romfs(os.path.join(NEW, fn))
ROM_N[tag] = r
tg = ip.targets(r)
for name in sorted(tg):
ino = tg[name]
blob = r.read(ino)
h, img = decode_lvgl_bin(blob)
slots.append(dict(tag=tag, dev=dev, image=fn, name=name, ino=ino, blob=blob,
h=h, img=img, bb=ink_bbox(img) if img is not None else None))
src = {}
ROM_O = {}
for fn in ['vela_app.bin', 'vela_health.bin']:
r = Romfs(os.path.join(OLD, fn))
ROM_O['app' if fn == 'vela_app.bin' else 'health'] = r
for name, ino in ip.targets(r).items():
if name in src: continue
h, img = decode_lvgl_bin(r.read(ino))
src[name] = (h, img, r.read(ino))
print('target slots %d source icons %d' % (len(slots), len(src)))
rows, newblobs, stock_of, skipped = [], {}, {}, []
for s in slots:
h = s['h']
if h is None:
skipped.append((s['name'], 'target not decodable')); continue
if s['name'] not in src:
skipped.append((s['name'], 'no OS4 source')); continue
sh, simg, sblob = src[s['name']]
if simg is None:
skipped.append((s['name'], 'source not decodable')); continue
src_from = 'q63 固件 %s' % s['image']
ov = OVERRIDE.get(s['name'])
if ov and os.path.exists(ov):
# 自制图覆盖:裁到实心 bbox -> 缩放到目标槽位的画法(136x136)-> 居中粘回 (4,4)
simg = Image.open(ov).convert('RGBA')
sblob = b''
src_from = '自制图 %s (%dx%d)' % (os.path.basename(ov), simg.size[0], simg.size[1])
print(' [override] %-14s <- %s %dx%d' % (s['name'], os.path.basename(ov),
simg.size[0], simg.size[1]))
out_img = normalize(simg, s['bb'], (h['w'], h['h']))
blob = ip.encode(out_img, h['cf'], h['w'], h['h'], h['flags'])
if len(blob) != s['ino'].size:
skipped.append((s['name'], 'length %d != slot %d' % (len(blob), s['ino'].size))); continue
exact = (blob == sblob)
newblobs[s['name']] = blob
stock_of[s['name']] = s['blob']
rows.append(dict(name=s['name'], tag=s['tag'], dev=s['tag'], node=s['dev'], image=s['image'],
offset=s['ino'].data_off, length=s['ino'].size,
dim='%dx%d' % (h['w'], h['h']), cf=ip.FMT.get(h['cf'], hex(h['cf'])),
stock_ink=s['bb'], source_from=src_from,
identical_to_stock=exact,
sha256=hashlib.sha256(blob).hexdigest(),
rb_sha256=hashlib.sha256(s['blob']).hexdigest()))
print('%-24s %-7s %-12s %-14s %s' % ('name', 'part', 'offset', 'slot', 'stock ink -> new'))
for s in slots:
r = next((x for x in rows if x['name'] == s['name']), None)
if r:
print('%-24s %-7s 0x%08x %-8d %-14s' % (r['name'], r['tag'], r['offset'], r['length'],
str(r['stock_ink']) + (' (identical)' if r['identical_to_stock'] else '')))
for n, why in skipped:
print('%-24s SKIP %s' % (n, why))
print('\nreplace %d skip %d' % (len(rows), len(skipped)))
# ------------------------------------------------- 系统 UI 素材:从 S5 41mm(q63) 搬过来
# 只搬「独立字形图标」(锁屏 / 通知中心 / 设置 / 米家)。控制中心的开关磁贴**故意没搬**:
# q63 的控制中心是白色主题(白盘 + 深色字形,运行时着色),S5 eSIM 是蓝色主题(预着色成品),
# 只搬那 4 张会变成「白盘混在蓝盘里」;要搬就得整套 ~40 张一起搬。
SYSICONS = [
('app', 'system/icon/lock_icon.bin', '锁屏上的锁'),
('app', 'common/icon/system_lock.bin', '状态栏系统锁'),
('app', 'common/icon/lock.bin', '通用锁'),
('app', 'system/icon/unlock.bin', '解锁提示页'),
('app', 'common/icon/quiet_mode.bin', '免打扰/静音小图标'),
('app', 'common/icon/unread_msg.bin', '未读消息红点'),
('app', 'notifications/default.bin', '通知默认大图标'),
('app', 'notifications/default_small.bin', '通知默认小图标'),
('app', 'notifications/no_message.bin', '通知中心空状态'),
('app', 'settings/icon/notify.bin', '设置-通知'),
('app', 'settings/disturb/confirm.bin', '免打扰确认按钮'),
('app', 'esimsms/icon/esim_sms_reminder.bin', '短信提醒'),
('app', 'esimsms/icon/sms_no_read.bin', '未读短信'),
('app', 'esimsms/icon/sms_reminder_watch.bin', '手表短信提醒'),
('app', 'phone/icon/send_sms_new.bin', '新建短信'),
('app', 'mijia/enable_donot_disturb.bin', '米家 免打扰开'),
('app', 'mijia/disable_donot_disturb.bin', '米家 免打扰关'),
('health', 'activities/icon/notification_stand.bin', '久坐提醒'),
('health', 'activities/icon/notification_vigor.bin', '活力提醒'),
# 压力 App 的小表情(4 档情绪 × 32/48/96,96 那三张两边本来就一样,会被自动跳过)
('health', 'pressure/icon/presure_relax.bin', '压力表情-放松 48'),
('health', 'pressure/icon/presure_mild.bin', '压力表情-轻度 48'),
('health', 'pressure/icon/presure_mid.bin', '压力表情-中度 48'),
('health', 'pressure/icon/presure_severe.bin', '压力表情-重度 48'),
('health', 'pressure/icon/presure_relax32.bin', '压力表情-放松 32'),
('health', 'pressure/icon/presure_mild32.bin', '压力表情-轻度 32'),
('health', 'pressure/icon/presure_mid32.bin', '压力表情-中度 32'),
('health', 'pressure/icon/presure_severe32.bin', '压力表情-重度 32'),
('health', 'pressure/icon/presure_relax96.bin', '压力表情-放松 96'),
('health', 'pressure/icon/presure_mild96.bin', '压力表情-轻度 96'),
('health', 'pressure/icon/presure_mid96.bin', '压力表情-中度 96'),
('health', 'pressure/icon/presure_severe96.bin', '压力表情-重度 96'),
('health', 'pressure/icon/presure_icon.bin', '压力 App 小图标'),
('health', 'pressure/icon/press_grade1.bin', '压力等级徽标 1'),
('health', 'pressure/icon/press_grade2.bin', '压力等级徽标 2'),
('health', 'pressure/icon/press_grade3.bin', '压力等级徽标 3'),
('health', 'pressure/icon/press_grade4.bin', '压力等级徽标 4'),
('health', 'pressure/icon/pressure_eomji_bg.bin', '表情底版'),
('health', 'pressure/icon/pressure_eomji_widbg.bin', '表情底版(小)'),
# 没有搬(体积大 / 不是表情):pressure_bg、success_bg、success_arc、
# press_widget_bg*、press_chart_bg、pressure_widget_pre_img*、reminder、measure/*
]
# 整目录一起搬(q63 有同名文件的才搬):控制中心的开关磁贴
SYSICONS_DIRS = ['control_center/icon/']
def find_inode(romfs, path):
for p, i in romfs.entries():
if p == path and i.type != 1:
return i
return None
print('\n--- 系统 UI 素材(锁屏 / 勿扰 / 通知 / 控制中心):q63 -> S5 eSIM ---')
_jobs = [(d, p, n) for d, p, n in SYSICONS]
_seen = {p for _, p, _ in _jobs}
for _dev in ROM_N:
for _p, _i in ROM_N[_dev].entries():
if _i.type == 1 or _p in _seen:
continue
if any(_p.startswith(_d) for _d in SYSICONS_DIRS):
_jobs.append((_dev, _p, '控制中心磁贴'))
_seen.add(_p)
for dev, path, note in _jobs:
ti = find_inode(ROM_N[dev], path)
oi = find_inode(ROM_O[dev], path)
if ti is None:
skipped.append((path, '目标固件没有这个文件')); continue
if oi is None:
skipped.append((path, 'q63 没有这个文件')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[dev].read(oi)
if src_blob == tgt_blob:
print(' %-52s 两边字节完全相同,跳过' % path)
continue
th = img_header(tgt_blob)
sh = img_header(src_blob)
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, '无法解码')); continue
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
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):
skipped.append((path, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
name = 'sys:' + path
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=path, note=note, how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=path,
offset=ti.data_off, length=ti.size,
dim=('%dx%d' % (th['w'], th['h'])) if th else '?',
cf=ip.FMT.get(th['cf'], hex(th['cf'])) if th else '?',
stock_ink=ink_bbox(decode_lvgl_bin(tgt_blob)[1]) if th and decode_lvgl_bin(tgt_blob)[1] else None,
source_from='q63 固件 / ' + path, identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
print(' %-52s %-22s %s' % (path, how, note))
# ------------------------------------------------- 对讲机素材(用户提供的整套 PNG)
# walkie_talkie/ 下的槽位几乎**全是 I8+RLE 压缩**的:不能像图标那样直接编码(长度会变),
# 必须走 ip.encode_into_slot():量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize,
# 这样字节数与原槽位完全相等,ROMFS 后面所有偏移一个字节都不动。
WALKIE_SRC = os.path.join(PROJ, 'assets', 'walkie_talkie')
# 对讲机素材:**默认不打包**(用户 2026-10-02 决定不改)。ICON_WALKIE=1 可打开。
DO_WALKIE = os.environ.get('ICON_WALKIE') == '1'
print('\n--- 对讲机素材 walkie_talkie(用户 PNG)-> S5 eSIM ---')
walkie_n = 0
if not DO_WALKIE:
print(' 按设置跳过这一组(ICON_WALKIE=1 可打开)')
elif not os.path.isdir(WALKIE_SRC):
print(' 没有 %s,跳过这一组' % 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'
dev = 'app'
ti = find_inode(ROM_N[dev], path)
if ti is None:
skipped.append((path, '目标固件没有这个槽位')); continue
tgt_blob = ROM_N[dev].read(ti)
img = Image.open(os.path.join(WALKIE_SRC, rel))
new, how = ip.encode_into_slot(img, tgt_blob, ti.size)
if new is None:
skipped.append((path, how)); continue
if new == tgt_blob:
print(' %-52s 与现有一致,跳过' % path); continue
th = img_header(tgt_blob)
name = 'walk:' + path
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='walkie', path=path, note='对讲机素材', how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=path,
offset=ti.data_off, length=ti.size,
dim=('%dx%d' % (th['w'], th['h'])) if th else '?',
cf=ip.FMT.get(th['cf'], hex(th['cf'])) if th else '?',
stock_ink=None, source_from='用户 PNG assets/walkie_talkie/' + rel,
identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
walkie_n += 1
if walkie_n <= 6 or walkie_n % 40 == 0:
print(' %-52s %-18s %d B' % (path, how, ti.size))
print(' 对讲机素材写入 %d 条' % walkie_n)
# ------------------------------------------------- 控制中心顶部状态区(q63 -> S5 eSIM)
# q63 的顶栏素材在 control_center/bar/:wifi 4 档 / 蜂窝信号 5 档 / 3G·4G 文字 /
# 蓝牙连接·断开 / 飞行模式 / 定位。本机同名槽位在 control_center/icon/(48x48 I8)。
# 尺寸不同的(q63 那几张 36x36)按**实心范围**归一化(normalize(simg, ink_bbox(timg), ...))。
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_connect_g.bin', 'control_center/bar/connect.bin', '蓝牙已连接手机(g)'),
('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'),
]
# 手机电量(本机 phone/icon/battery_level_*,64x22):q63 同名但 64x32
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))
print('\n--- 控制中心顶部状态区(q63 control_center/bar -> S5 eSIM)---')
cc_top_n = 0
for tpath, spath, note in CC_TOP:
ti = find_inode(ROM_N['app'], tpath)
if ti is None:
ti = find_inode(ROM_N['health'], tpath)
dev = 'health'
else:
dev = 'app'
if ti is None:
skipped.append((tpath, '本机没有这个槽位')); continue
oi = find_inode(ROM_O[dev], spath)
if oi is None:
skipped.append((spath, 'q63 没有这个素材')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[dev].read(oi)
th = img_header(tgt_blob)
sh = img_header(src_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
out = normalize(simg, ink_bbox(timg), (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 len(new) != len(tgt_blob):
failed.append((tpath, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
# 与 SYSICONS 组同名 -> 同一槽位只保留一条记录(这里覆盖那边的旧风格版本)
name = 'sys:' + tpath
rows[:] = [r for r in rows if r['name'] != name]
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=tpath, note=note, how='归一化(%s)' % note,
tag=dev, dev=dev, node='/dev/' + dev, image=tpath,
offset=ti.data_off, length=ti.size,
dim='%dx%d' % (th['w'], th['h']),
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
stock_ink=ink_bbox(timg), source_from='q63 ' + spath,
identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
cc_top_n += 1
print(' %-46s <- %-40s %s' % (tpath, spath, note))
print(' 顶栏状态区写入 %d 条' % cc_top_n)
# ------------------------------------------------- 米家 / 融合中心(控制中心里的米家设备图标)
# q63 的 fusion_center/** 与 mijia/** 里**同名路径**本机也有的就移植;本机还有一份
# fusion_center/gauss/**(同名的一并写)。画布尺寸两边常常不同(q63 116/64 vs 本机 96/128),
# 按**实心范围**对齐并转成本机槽位的格式。
PLATE_KEEP = ('headset',) # 只有这种「裸图案」槽位保留底版 + 图案归位
# 控制中心的米家/融合中心那组:**默认不打包**(用户 2026-10-02 决定不改)。ICON_MIJIA=1 可打开。
DO_MIJIA = os.environ.get('ICON_MIJIA') == '1'
# q63 的「柔光玻璃」底盘(干净的深色玻璃圆盘 + 发丝高光环)
_glass = None
_g = find_inode(ROM_O['app'], 'fusion_center/fg.bin')
if _g is not None:
_glass = decode_lvgl_bin(ROM_O['app'].read(_g))[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/')
def plate_glyph_bbox(img, tol=26, min_plate=0.30):
"""本机图标是「底版 + 图案」时,返回底版上图案的 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):
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 normalize_fit(src_img, target_bb, canvas):
"""等比缩放源实心区域到能放进 target_bb 的最大尺寸并居中(不拉伸)。"""
cw, ch = canvas
cv = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
if target_bb is None:
return cv
nb = ink_bbox(src_img)
if nb is None:
return cv
tw, th = 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], th / 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))
cv.paste(art, (target_bb[0] + (tw - nw) // 2, target_bb[1] + (th - nh) // 2))
return cv
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])
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 -> S5 eSIM)%s ---' % ('' if DO_MIJIA else ':已按设置跳过'))
mijia_n = 0
_mijia_srcs = []
if DO_MIJIA:
for _dev0 in ('app', 'health'):
for _p, _i in ROM_O[_dev0].entries():
if _i.type == 2 and _p.startswith(MIJIA_PREFIX) and not _p.endswith('launcher.bin'):
_mijia_srcs.append((_p, _dev0))
for _q, _srcdev in sorted(set(_mijia_srcs)):
cands = [_q]
if _q.startswith('fusion_center/icon/'):
cands.append(_q.replace('fusion_center/icon/', 'fusion_center/gauss/'))
for tpath in cands:
dev = 'app'
ti = find_inode(ROM_N[dev], tpath)
if ti is None:
ti = find_inode(ROM_N['health'], tpath)
dev = 'health'
if ti is None:
if tpath == _q:
skipped.append((tpath, '本机没有这个槽位'))
continue
oi = find_inode(ROM_O[_srcdev], _q)
if oi is None:
skipped.append((_q, 'q63 没有这个素材')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[_srcdev].read(oi)
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
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
new, how = ip.encode_into_slot(simg, tgt_blob, ti.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)
pal = None
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, '索引层合成不可用')); continue
out = None
how = '底版原样+图案入索引 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
else:
out = normalize_fit(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
if out is not None:
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
if new is None or len(new) != ti.size:
skipped.append((tpath, '编码失败/长度不符')); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
name = 'sys:' + tpath
rows[:] = [r for r in rows if r['name'] != name]
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=tpath, note='米家/融合中心', how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=tpath,
offset=ti.data_off, length=ti.size,
dim='%dx%d' % (th['w'], th['h']),
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
stock_ink=ink_bbox(timg) if 'timg' in dir() and timg else None,
source_from='q63 ' + _q, identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
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/** -> 本机)
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 组管,这里跳过避免重复写同一槽位
PORT_SKIP_TAIL = ('launcher.bin', 'launcher')
print('\n--- 通知/电话/录音机素材(q63 同名移植 -> S5 eSIM)---')
notif_n = 0
for _q, _srcdev in sorted((p, 'app') for p, i in ROM_O['app'].entries()
if i.type == 2 and p.startswith(PORT_PREFIXES)):
if _q in NOTIF_SKIP:
skipped.append((_q, '按约定保持原厂')); continue
if _q.rsplit('/', 1)[-1].endswith('launcher.bin'):
continue # 应用图标归 launcher 组管
dev = 'app'
ti = find_inode(ROM_N[dev], _q)
if ti is None:
ti = find_inode(ROM_N['health'], _q)
dev = 'health'
if ti is None:
skipped.append((_q, '本机没有这个槽位')); continue
oi = find_inode(ROM_O[_srcdev], _q)
if oi is None:
skipped.append((_q, 'q63 没有这个素材')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[_srcdev].read(oi)
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
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
new, how = ip.encode_into_slot(simg, tgt_blob, ti.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_fit(simg, ink_bbox(timg), (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)
how = '归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
if len(new) != ti.size:
skipped.append((_q, '编码 %d != 槽位 %d' % (len(new), ti.size))); continue
if new == tgt_blob:
skipped.append((_q, '两边已经一致')); continue
name = 'sys:' + _q
rows[:] = [r for r in rows if r['name'] != name]
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=_q, note='焦点通知', how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=_q,
offset=ti.data_off, length=ti.size,
dim='%dx%d' % (th['w'], th['h']),
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
stock_ink=ink_bbox(timg) if 'timg' in dir() and timg else None,
source_from='q63 ' + _q, identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
notif_n += 1
print(' %-46s %s' % (_q, how))
print(' 通知/电话/录音机写入 %d 条' % notif_n)
# ------------------------------------------------------------------ payload files
pay = MAGIC_PAY + b''.join(newblobs[r['name']] for r in rows)
rb = MAGIC_RB + b''.join(stock_of[r['name']] for r in rows)
open(os.path.join(WF, 'payload', 'payload.bin'), 'wb').write(pay)
open(os.path.join(WF, 'payload_rollback', 'rollback.bin'), 'wb').write(rb)
def lua_table(recs, var):
lines = ['-- %s: {设备, 分区内偏移, 长度, payload 文件内相对偏移} —— 由 _tools/s5e_payload.py 生成' % var,
'local %s = {' % var]
rel = 0
for r in recs:
lines.append(' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], rel))
rel += len(stock_of[r['name']])
lines.append('}')
return '\n'.join(lines) + '\n'
open(os.path.join(WF, 'payload', 'payload.lua'), 'w', encoding='utf-8', newline='\n').write(
lua_table(rows, 'PAY'))
open(os.path.join(WF, 'payload_rollback', 'rollback.lua'), 'w', encoding='utf-8', newline='\n').write(
lua_table(rows, 'RB'))
json.dump(dict(target='Xiaomi Watch S5 eSIM 46mm (p62lte) v3.112.035',
source='Xiaomi Watch S5 41mm (q63) v4.101.020 launcher icons',
device_nodes={'app': '/dev/app', 'health': '/dev/health'},
payload_file='payload.bin', payload_magic=MAGIC_PAY.hex(),
rollback_file='rollback.bin', rollback_magic=MAGIC_RB.hex(),
count=len(rows), records=rows,
skipped=[dict(name=n, reason=w) for n, w in skipped]),
open(os.path.join(OUT, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
# ------------------------------------------------------------------ patched whole images
for tag, fn, dev in PART:
srcp = os.path.join(NEW, fn)
dstp = os.path.join(OUT, fn.replace('.bin', '_os4.bin'))
shutil.copyfile(srcp, dstp)
with open(dstp, 'r+b') as f:
for r in rows:
if r['tag'] == tag:
f.seek(r['offset'])
f.write(newblobs[r['name']])
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(srcp).entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(dstp).entries()]
r1, r2 = Romfs(srcp), Romfs(dstp)
i1 = {p: i for p, i in r1.entries()}
i2 = {p: i for p, i in r2.entries()}
changed = sorted(p for p in i1 if r1.read(i1[p]) != r2.read(i2[p]))
# 期望变化的文件 = 所有 new != stock 的记录(launcher 图标 + 系统 UI 素材)
mine = [r for r in rows if r['tag'] == tag and newblobs[r['name']] != stock_of[r['name']]]
sys_paths = {r['path'] for r in mine if r.get('kind') in ('sysicon', 'walkie')}
unexpected = [p for p in changed
if not (p.rsplit('/', 1)[-1].endswith('launcher.bin') or p in sys_paths)]
print('\n%s -> %s' % (fn, os.path.basename(dstp)))
print(' entries %d, inode table identical: %s, superblock identical: %s' % (
len(a), a == b, (r1.sb_size, r1.sb_cksum) == (r2.sb_size, r2.sb_cksum)))
print(' changed files: %d (expected %d) sha256 %s' % (
len(changed), len(mine), hashlib.sha256(open(dstp, 'rb').read()).hexdigest()[:16]))
assert not unexpected, 'unexpected changed file! %s' % unexpected[:3]
assert len(changed) == len(mine), 'changed %d != expected %d' % (len(changed), len(mine))
# 端到端:把负载按 (偏移,长度) 放回去,必须与改后镜像逐字节相同
d = bytearray(open(srcp, 'rb').read())
for r in rows:
if r['tag'] == tag:
d[r['offset']:r['offset'] + r['length']] = newblobs[r['name']]
assert bytes(d) == open(dstp, 'rb').read(), 'payload replay != patched image'
print(' payload replay == patched image: True')
# 回滚也必须能逐字节还原原厂镜像
e = bytearray(open(dstp, 'rb').read())
for r in rows:
if r['tag'] == tag:
e[r['offset']:r['offset'] + r['length']] = stock_of[r['name']]
orig = open(srcp, 'rb').read()
assert bytes(e) == orig, 'rollback replay != stock image'
print(' rollback replay == stock image: True (sha256 %s)'
% hashlib.sha256(orig).hexdigest()[:16])
print('\n-> %s' % OUT)
+30
View File
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""Render the 480x480 page spec with the real MiSans fonts (previews + overview)."""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import render_ui as R
from PIL import Image
from paths import DOCS
R.W = R.H = 480
from s5e_pages import PAGES
outs = []
for name, items in PAGES.items():
p = os.path.join(DOCS, 's5e_ui_%s.png' % name)
R.render(items, p)
outs.append((name, p))
print('wrote', p)
if R.OVERFLOW:
print('!! label overflow (text, lh, lines, total, box, natural):')
for o in R.OVERFLOW:
print(' ', o)
else:
print('no label overflow')
cols = 3
rows = (len(outs) + cols - 1) // cols
sheet = Image.new('RGB', (cols * (480 + 12) + 12, rows * (480 + 12) + 12), (28, 28, 32))
for i, (name, p) in enumerate(outs):
sheet.paste(Image.open(p), (12 + (i % cols) * 492, 12 + (i // cols) * 492))
sp = os.path.join(DOCS, 's5e_ui_all.png')
sheet.save(sp)
print('wrote', sp, sheet.size)
+38
View File
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""UI image assets for the 480 face + the 326 preview / 80 logo for the builder."""
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')
os.makedirs(OUT, exist_ok=True)
SPEC = [('back', 'backarrow.png', 18, 29),
('ic_help', 'MdiLightbulbOutline.png', 31, 31),
('ic_replace', 'MdiBandage.png', 31, 31),
('ic_about', 'MdiInformationOutline.png', 31, 31),
('ic_check', 'MdiClipboardTextSearchOutline.png', 31, 31),
('ic_rollback', 'MdiReplyAllOutline.png', 31, 31),
('ic_qq', 'qq.png', 31, 31)]
for name, png, w, h in SPEC:
p = os.path.join(ASSETS, png)
if not os.path.exists(p):
print(' missing', png); continue
img = Image.open(p).convert('RGBA')
if img.size != (w, h):
img = img.resize((w, h), Image.LANCZOS)
blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0)
open(os.path.join(OUT, name + '.bin'), 'wb').write(blob)
print(' %-12s <- %-34s %dx%d %d bytes' % (name, png, w, h, len(blob)))
L = 80
img = Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA').resize((L, L), Image.LANCZOS)
mask = Image.new('L', (L, L), 0)
ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255)
img.putalpha(mask)
open(os.path.join(OUT, 'logo.bin'), 'wb').write(ip.encode(img, ip.CF_ARGB8888, L, L, 0))
img.save(os.path.join(DOCS, 'logo_preview_480.png'))
print(' %-12s <- cover.png(circular) %dx%d' % ('logo', L, L))
print('->', OUT)
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""Independent end-to-end check of the delivered .face.
Nothing here trusts the build script's bookkeeping: the Lua table is parsed out of
the face and every offset is validated against the *firmware images* (stock and
patched) and the ui_images directory.
"""
import os, re, sys, json, hashlib, struct
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from romfs import Romfs
from paths import PROJ, INPUTS, WORK, DIST
WF = PROJ
NEW = os.path.join(INPUTS, 'target')
PAT = os.path.join(WORK, 's5e_from_os4')
FACE = os.path.join(DIST, 'S5eIcons.face')
IMAGES = {'app': ('vela_app.bin', 'vela_app_os4.bin'), 'health': ('vela_health.bin', 'vela_health_os4.bin')}
MANIFEST = os.path.join(PAT, 'manifest.json')
face = open(FACE, 'rb').read()
# 直接从 .face 里把 Lua 抠出来校验(不依赖构建目录)
_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:]))
fails = []
def ck(cond, msg):
print((' OK ' if cond else ' FAIL ') + msg)
if not cond:
fails.append(msg)
print('== 头部 ==')
ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12')
ck(face[40:50] == b'462150101\x00', 'id = %r' % face[40:50])
ck(face[5] == 0, 'b5 = %d(固件自带表盘 b5 也是 0)' % face[5])
a16, = struct.unpack('<I', face[16:20])
a32, = struct.unpack('<I', face[32:36])
print(' info @16=%d @32=0x%x 文件 %d B' % (a16, a32, len(face)))
print('== 预览块 ==')
pal, pw, ph, psz = struct.unpack('<IHHI', face[a32:a32 + 12])
ck(pw == 326 and ph == 326, 'preview %dx%d(Compiler.exe 对 462 设备强制 326x326)' % (pw, ph))
ck(a32 + 12 + psz <= len(face) + 1,
'preview 数据 %d B @0x%x(文件尾 0x%x)' % (psz, a32 + 12, len(face)))
print('== Lua 源码 ==')
ck(face.count(lua) == 1, 'Lua 源码原样出现 %d 次(%d B)' % (face.count(lua), len(lua)))
off_lua = face.find(lua)
ck(off_lua > 0, 'Lua 偏移 0x%x' % off_lua)
txt = lua.decode('utf-8')
print('== UI 图片(从 .face 里的 Lua 表读出来校验)==')
ui = re.findall(r'\{ (\d+), (\d+), "([a-z_]+)" \}', txt)
ck(len(ui) == 8, 'UIIMG 条目 %d 条' % len(ui))
files = {f[:-4]: open(os.path.join(WF, 'ui_images', f), 'rb').read()
for f in os.listdir(os.path.join(WF, 'ui_images')) if f.endswith('.bin')}
for o, l, n in ui:
o, l = int(o), int(l)
ck(n in files and face[o:o + l] == files[n], '%(n)-12s @0x%(o)06x len=%(l)d 与 ui_images/%(n)s.bin 一致' % dict(n=n, o=o, l=l))
print('== 负载表 ==')
pay = re.findall(r'\{ "(app|health)", 0x([0-9a-f]+), (\d+), (\d+) \}', txt)
blocks = re.findall(r'^ (pay|rb) = \{', txt, re.M)
ck(blocks == ['pay', 'rb'], 'pay / rb 两个表都在')
half = len(pay) // 2
exp = json.load(open(MANIFEST, encoding='utf-8'))['records']
ck(half == len(exp) and len(pay) == 2 * len(exp),
'pay %d 条 + rb %d 条(manifest 里 %d 条)' % (half, len(pay) - half, len(exp)))
pay_recs = [dict(dev=d, off=int(o, 16), ln=int(l), res=int(r)) for d, o, l, r in pay[:half]]
rb_recs = [dict(dev=d, off=int(o, 16), ln=int(l), res=int(r)) for d, o, l, r in pay[half:]]
stock, patched, sizes = {}, {}, {}
for dev, (sf, pf) in IMAGES.items():
rs, rp = Romfs(os.path.join(NEW, sf)), Romfs(os.path.join(PAT, pf))
stock[dev] = rs.data
patched[dev] = rp.data
sizes[dev] = rs.sb_size
bad = 0
for i, (p, r) in enumerate(zip(pay_recs, rb_recs)):
assert p['dev'] == r['dev'] and p['off'] == r['off'] and p['ln'] == r['ln'], 'record %d 表错位' % i
d, o, l = p['dev'], p['off'], p['ln']
ok = (face[p['res']:p['res'] + l] == patched[d][o:o + l] and
face[r['res']:r['res'] + l] == stock[d][o:o + l] and
o % 4 == 0 and o + l <= sizes[d] and # RLE 素材的长度可以是任意值,只要偏移对齐
len(face[p['res']:p['res'] + l]) == l and len(face[r['res']:r['res'] + l]) == l)
if not ok:
bad += 1
print(' FAIL %-22s %s off=0x%x len=%d' % (d, p['dev'], o, l))
ck(bad == 0, '%d 条:负载字节 == 改后镜像,回滚字节 == 原厂镜像,且都 4 对齐、不越界' % half)
devs = {}
for p in pay_recs:
devs[p['dev']] = devs.get(p['dev'], 0) + 1
exp_devs = {}
for r in exp:
exp_devs[r['dev']] = exp_devs.get(r['dev'], 0) + 1
ck(devs == exp_devs, '分区分布 %s(manifest %s)' % (devs, exp_devs))
print('== 负载文件在 .face 里连续存放 ==')
pbin = open(os.path.join(WF, 'payload', 'payload.bin'), 'rb').read()
rbin = open(os.path.join(WF, 'payload_rollback', 'rollback.bin'), 'rb').read()
ck(face.count(pbin) == 1 and face.count(rbin) == 1,
'payload %d B / rollback %d B 各出现 1 次' % (len(pbin), len(rbin)))
rel = sorted(x['res'] - (face.find(pbin) + 15) for x in pay_recs)
ck(rel[0] == 0, '第一条负载相对偏移为 0')
ck(face[pay_recs[0]['res'] - 15:pay_recs[0]['res']] == pbin[:15], 'magic 紧贴第一条负载')
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]))