增添 S5 支持

This commit is contained in:
riseforever2026
2026-10-03 00:01:12 +08:00
parent d4dff20600
commit 9a80b9de30
338 changed files with 9463 additions and 64 deletions
+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')
+168
View File
@@ -0,0 +1,168 @@
"""LVGL v9 RLE codec (LV_IMAGE_COMPRESS_RLE).
Control byte grammar (upstream `lv_rle_decompress`):
high bit CLEAR (0x00..0x7F) : repeat the next single `blk`-byte block `ctrl` times
high bit SET (0x80|count) : copy `count` blocks verbatim (literal run)
count is 1..=127
For palette formats (I8) the stream decodes to 256*4 palette bytes followed by
w*h palette indices.
"""
import struct
from PIL import Image
MAGIC = 0x19
FLAG_COMPRESSED = 0x08
METHOD_NONE, METHOD_RLE, METHOD_LZ4 = 0, 1, 2
def rle_decompress(data, blk, out_len=None):
out = bytearray()
i, n = 0, len(data)
while i < n:
ctrl = data[i]; i += 1
if ctrl & 0x80:
cnt = ctrl & 0x7F
nb = cnt * blk
if i + nb > n:
raise ValueError('truncated literal run')
out += data[i:i + nb]
i += nb
else:
cnt = ctrl
if i + blk > n:
raise ValueError('truncated repeat unit')
unit = data[i:i + blk]
i += blk
out += unit * cnt
if out_len is not None and len(out) >= out_len:
break
return bytes(out)
def rle_compress(raw, max_run=127):
"""LVGL RLE 编码器(rle_decompress 的逆向,blk=1)。
贪心:先找同字节游程(>=2 用「重复」指令),否则攒「原样」指令。
输出长度总是 <= 原图像素流,因为最坏情况每条 literal 只多 1 个控制字节。
"""
out = bytearray()
i, n = 0, len(raw)
while i < n:
v = raw[i]
j = i + 1
while j < n and raw[j] == v and j - i < max_run:
j += 1
run = j - i
if run >= 2:
out.append(run); out.append(v); i = j
continue
lit = bytearray()
k = i
while k < n and len(lit) < max_run:
if k + 1 < n and raw[k] == raw[k + 1]:
break
lit.append(raw[k]); k += 1
if not lit: # 理论上到不了这里
lit.append(v); k = i + 1
out.append(0x80 | len(lit)); out += lit
i = k
return bytes(out)
def pad_rle(comp, want):
"""把 RLE 流补到 want 字节:用 ctrl=0(重复 0 次 = 产出 0 像素)的 2 字节空指令。
ctrl=0 是合法指令(`len = 0`,两个越界判断都不触发),所以设备端解码器读完 csize
字节既不会越界、也不会返回 INVALID —— 这是「等长覆盖」能成立的关键。
差值为奇数时先把一条 literal 拆成两条(payload 不变、多 1 个控制字节)。
"""
d = want - len(comp)
if d < 0:
return None
if d % 2:
b = bytearray(comp)
for idx in range(len(b) - 1, -1, -1):
if b[idx] & 0x80 and (b[idx] & 0x7F) >= 2:
cnt = b[idx] & 0x7F
lit = bytes(b[idx + 1: idx + 1 + cnt])
half = cnt // 2
b[idx: idx + 1 + cnt] = (bytes([0x80 | half]) + lit[:half] +
bytes([0x80 | (cnt - half)]) + lit[half:])
break
else:
return None
comp = bytes(b)
d -= 1
return comp + b'\x00\x00' * (d // 2)
def parse_header(blob):
if len(blob) < 12 or blob[0] != MAGIC:
return None
magic, cf, flags, w, h, stride, rsv = struct.unpack('<BBHHHHH', blob[:12])
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=rsv)
def decode_lvgl_bin(blob):
"""Return (header, RGBA PIL image) for uncompressed / RLE LVGL v9 .bin.
NOTE stride: LVGL pads each row to the header's `stride`, which is NOT always
equal to w (e.g. the S5 41mm control-center icons are 116 wide with stride 128).
Reading them as w*h contiguous pixels yields rainbow garbage, so the row pitch
below is taken from the header.
"""
h = parse_header(blob)
if h is None:
return None, None
cf, w, ht = h['cf'], h['w'], h['h']
body = blob[12:]
if h['flags'] & FLAG_COMPRESSED:
method, csize, usize = struct.unpack('<III', body[:12])
if method != METHOD_RLE:
raise ValueError(f'unsupported compression method {method}')
blk = 4 if cf == 0x10 else 1 # ARGB8888 是 4 字节一块,I8 是 1 字节
raw = rle_decompress(body[12:12 + csize], blk, usize)
else:
raw = body
img = Image.new('RGBA', (w, ht))
px = img.load()
if cf == 0x10: # ARGB8888 (BGRA)
pitch = max(h['stride'] or 0, w * 4)
if len(raw) < pitch * ht:
return h, None
for y in range(ht):
row = y * pitch
for x in range(w):
b, g, r, a = raw[row + x * 4:row + x * 4 + 4]
px[x, y] = (r, g, b, a)
elif cf == 0x0a: # I8: palette then indices
pitch = max(h['stride'] or 0, w)
if len(raw) < 1024 + pitch * ht:
return h, None
pal = raw[:1024]
palette = [(pal[i*4+2], pal[i*4+1], pal[i*4+0], pal[i*4+3]) for i in range(256)]
for y in range(ht):
row = 1024 + y * pitch
for x in range(w):
px[x, y] = palette[raw[row + x]]
else:
return h, None
return h, img
def pad_rle_safe(comp, want):
"""把 RLE 流补齐到 want 字节 —— 只用两类**产出 0 像素**的合法指令:
ctrl=0x00 + unit : 重复 0 次(2 字节)
0x80 : 原样 0 个(1 字节,用来修奇偶)
两者都能通过上游 lv_rle_decompress 的边界判断(len 为 0,不越界)。
注意:不要像 pad_rle 那样去“拆一条 literal”修奇偶 —— 那是按裸字节扫描的,
会命中负载里的普通字节、把流改坏(本工具就是踩了这个坑才发现)。"""
d = want - len(comp)
if d < 0:
return None
out = bytearray(comp)
out += b'\x00\x00' * (d // 2)
if d % 2:
out.append(0x80)
assert len(out) == want
return bytes(out)
+50
View File
@@ -0,0 +1,50 @@
"""项目里所有脚本的路径入口 —— 换机器 / 换目录只看这一个文件。
默认布局(都相对项目根):
inputs/target/ 目标固件的分区镜像(要自己放,见 README)
inputs/source/ 素材来源的分区镜像(要自己放)
inputs/fonts/ 可选:MiSans-*.ttf(渲染预览用;不给就从 vela_font.bin 里取)
vendor/ 第三方 LuaDevTemplate-main(里面有 Compiler.exe,要自己放)
.work/ 构建工作区(产物,可删)
dist/ 交付的 .face
docs/ 说明与预览图
环境变量可覆盖:ICON_EDIT_INPUTS / ICON_EDIT_VENDOR / ICON_EDIT_WORK /
ICON_EDIT_DIST / ICON_EDIT_DOCS / ICON_EDIT_FONTS
"""
import os
PROJ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _p(env, default):
return os.environ.get(env) or os.path.join(PROJ, default)
INPUTS = _p('ICON_EDIT_INPUTS', 'inputs')
VENDOR = _p('ICON_EDIT_VENDOR', 'vendor')
WORK = _p('ICON_EDIT_WORK', '.work')
DIST = _p('ICON_EDIT_DIST', 'dist')
DOCS = _p('ICON_EDIT_DOCS', 'docs')
FONTS = os.environ.get('ICON_EDIT_FONTS') or os.path.join(INPUTS, 'fonts')
TEMPLATE = os.path.join(VENDOR, 'LuaDevTemplate-main')
INPUT_TARGET = os.path.join(INPUTS, 'target')
INPUT_SOURCE = os.path.join(INPUTS, 'source')
ASSETS = os.path.join(PROJ, 'assets')
for _d in (WORK, DIST, DOCS, INPUT_TARGET, INPUT_SOURCE):
os.makedirs(_d, exist_ok=True)
# ------------------------------------------------------------------ 本项目的事实
# 目标:Xiaomi Watch S5 eSIM 46mm(p62lte, 480x480, Lua 5.4 宿主)
# 素材来源:Xiaomi Watch S5 41mm(q63, v4.101.020)
TARGET_IMAGE = os.path.join(INPUT_TARGET, 'vela_health.bin') # 要改的分区(卷名就是 health)
SOURCE_IMAGE = os.path.join(INPUT_SOURCE, 'vela_app.bin') # q63 的 100 帧在这里
TARGET_VOLUME = 'health'
DIRP = 'pressure/measure/'
FACE_W = FACE_H = 464 # 槽位是 464x464 I8 + RLE
SRC_W = SRC_H = 480 # q63 的帧是 480x480
SCREEN = 480 # 表盘界面 480x480
WF_ID = '462150102' # 两份盘共用(462 = S5 家族)
DEVICE_TYPE = '462'
PREVIEW = 326 # Compiler.exe 对 462 设备强制 326x326
+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}')
+562
View File
@@ -0,0 +1,562 @@
# -*- coding: utf-8 -*-
"""把 S5 41mm(q63) 的 `pressure/measure/Measuring1..100.bin`(480x480)逐帧等长写进
S5 eSIM(p62lte) `vela_health.bin` 的 `pressure/measure/Measuring1..119.bin`(464x464 I8+RLE)。
硬约束(为什么必须"逐字节等长")
--------------------------------
ROMFS 里 `next` / `spec` 全是**绝对偏移**,文件数据又紧随 inode 之后;任何一条负载
长度变化 1 字节,后面所有文件的偏移就整体平移 → 分区报废。所以:
12 B 头 + 12 B 压缩头 + csize 必须 == 原槽位长度
csize 原样沿用(解码器按 csize 读),usize 原样沿用(= 1024 + 464*464 = 216321)
四步流水线
----------
1) 解码 q63 帧 → 缩放到 464x464(**缩小**,LANCZOS)
2) 暗部归零:q63 帧的"黑"其实是一条均匀的 #1a1a1a(26,26,26) 底噪 + 抖动噪声,
实测占 68% 的像素、熵极高(256 色几乎全糊在暗部,不处理时 RLE 压不动:172 KB)。
阈值以下的像素一律置 0,背景塌成整行单游程,体积掉到 1/4(~45 KB)。
3) 量化:按一条**单调质量阶梯**(缩放比例 + 颜色数一起动)挑能塞进 csize 的最高档。
4) LVGL RLE 压缩 → 先补「重复 N 次」把解码长度做到**正好 usize**,
再用 `pad_rle_safe` 的合法空指令(00 00 / 80)补齐到 csize。
为什么用"质量单参数"而不是逐槽各自最优
--------------------------------------
槽位预算从 4.5 KB 到 196 KB 差 40 倍。逐帧各自最优会让相邻帧出现
「2 色 x0.32」跳到「4 色 x0.80」这种忽大忽小(S4e 那版踩过)。这里把
(颜色数, 缩放) 绑成一个连续量,先算每槽能撑住的最大档,再从峰值向两侧取
「运行最小值」—— 曲线单峰且平滑,画面尺寸/色阶随预算连续变化。
产出
----
.work/anim/vela_health_anim.bin 改后镜像(长度 == 原镜像)
.work/anim/manifest.json 逐条清单(偏移/长度/映射/降级/保留)
"""
import os, sys, json, struct, shutil, hashlib, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import (PROJ, INPUTS, WORK, TARGET_IMAGE, SOURCE_IMAGE, TARGET_VOLUME,
DIRP, FACE_W, FACE_H)
from romfs import Romfs
import lvgl_bin as L
from PIL import Image
import numpy as np
OUTDIR = os.path.join(WORK, 'anim')
PATCHED = os.path.join(OUTDIR, 'vela_health_anim.bin')
# 设备端解码器要求流解出的字节数**正好** == 头里的 usize。原厂 119 条全是 216321,
# 比"调色板 + 像素"多 1 字节(解码器只用到前 1024 + 464*464 字节,多出来的 1 字节
# 落在行外、被 `y < h` 挡掉,所以既安全又是原厂行为)。实测值由 build() 从镜像里读出来,
# 不在这儿写常量 —— 写错过一次(写成 216320)就被下面的断言当场抓住。
USIZE = 1024 + FACE_W * FACE_H # 216320:调色板 + 像素;真实值以镜像为准
TAIL_EXTRA = 1 # 期望的原厂尾部额外字节数
USIZE_EXTRA = TAIL_EXTRA # 实际值由 build() 从镜像读出来后覆盖
# ------------------------------------------------------------------ 暗部归零
# q63 帧的底噪是 #1a1a1a(26),球体最暗的实体像素也 >100。取 40 吃掉整片底噪与抖动,
# 又不动球体本体;56 作为第二档兜底(更狠,留给塞不下的槽位)。
DARK_LEVELS = (40, 56)
# ------------------------------------------------------------------ 固定调色板(低预算兜底)
# 到 8 色以下时"重新聚类"会把仅剩的颜色浪费在底噪残渣上、球体反而变花;
# 改用这几套手工调色板(索引 0 一定是黑),画面干净、也更好压。
FIXED_PALETTES = {
2: [(0, 0, 0), (92, 132, 255)],
3: [(0, 0, 0), (40, 60, 190), (120, 190, 255)],
4: [(0, 0, 0), (28, 40, 150), (70, 120, 245), (140, 220, 210)],
6: [(0, 0, 0), (18, 26, 110), (44, 66, 200), (80, 130, 250),
(140, 200, 245), (150, 220, 160)],
8: [(0, 0, 0), (12, 18, 80), (26, 38, 140), (46, 70, 195), (72, 110, 240),
(110, 165, 252), (165, 215, 250), (150, 222, 168)],
}
# ------------------------------------------------------------------ 质量阶梯
# 从「最好」到「最省」单调排列:(缩放, 颜色数, 固定调色板色数|None)
LADDER = [
(1.00, 256, None), (1.00, 192, None), (1.00, 128, None), (1.00, 96, None),
(0.95, 96, None), (0.95, 64, None), (0.90, 64, None),
(0.85, 64, None), (0.85, 48, None), (0.80, 48, None),
(0.75, 48, None), (0.75, 32, None), (0.70, 32, None),
(0.65, 32, None), (0.65, 24, None), (0.60, 24, None),
(0.55, 24, None), (0.55, 16, None), (0.50, 16, None),
(0.45, 16, None), (0.45, 12, None), (0.40, 12, None),
(0.35, 12, None), (0.35, 8, 8), (0.30, 8, 8),
(0.26, 6, 6), (0.22, 6, 6), (0.19, 4, 4), (0.16, 4, 4),
(0.13, 3, 3), (0.11, 3, 3), (0.09, 2, 2), (0.07, 2, 2), (0.05, 2, 2),
]
CI_KEEP = len(LADDER) # 哨兵:连最省档都塞不下
# 平滑之后允许比目标档**大**多少缩放:0 表示只许更小。留一点余量是为了让球体尽量顶到
# 预算上限,同时不给相邻帧制造肉眼可见的尺寸跳变(每档缩放差约 0.05)。
SCALE_SLACK = 0.02
def num_of(path):
return int(path.rsplit('Measuring', 1)[-1].replace('.bin', ''))
def collect(rom):
d = {}
for p, i in rom.entries():
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0:
continue
d[num_of(p)] = (p, i, rom.read(i))
return d
# ------------------------------------------------------------------ 编码原语
def palette_bytes(cols):
"""1024 B BGRA;索引 0 一定是黑,没用的条目留 0 —— 调色板本身也好压。"""
out = bytearray(1024)
for k, (r, g, b) in enumerate(cols[:256]):
out[k * 4:k * 4 + 4] = bytes((b, g, r, 255))
return bytes(out)
def kill_dark(img, dark):
a = np.asarray(img.convert('RGB')).copy()
a[a.max(axis=2) < dark] = 0
return Image.fromarray(a, 'RGB')
def scale_image(img, sc):
if sc >= 0.999:
return img
s = max(4, int(round(FACE_W * sc)))
small = img.convert('RGBA').resize((s, s), Image.LANCZOS)
cv = Image.new('RGBA', (FACE_W, FACE_H), (0, 0, 0, 255))
cv.paste(small, ((FACE_W - s) // 2, (FACE_H - s) // 2))
return cv
def quantize_raw(rgb, ncol, fixed):
"""-> 1024 B 调色板 + w*h 个索引(LVGL I8 的裸像素流)。
自己实现量化,不再借 PIL 的调色板 API —— 这里踩过两个坑,留作警钟:
① 为了"保证索引 0 是黑",先把图贴到 (W+8)x(H+8) 黑画布上再 quantize,
然后 `q.tobytes()[:W*H]` 取像素。但 q 是 472 宽的,按 464 宽读就"每行错 8 列",
整幅图被剪成斜条纹 —— RLE 流完全正确、解出来也和"量化结果"逐点一致,
只有拿"量化结果的形状"去比**源图**才抓得住(见 silhouette())。
② 改成"循环移位调色板 + convert()/paste()"更糟:`quantize(palette=...)` 会把图重新
合并成它自己算出来的调色板,`paste()` 又只搬调色板,于是 64 色时整幅图变纯黑。
与其猜 PIL 的语义,不如把量化写成几行明确的 numpy。
现在:黑单独占索引 0(背景因此塌成单游程),其余 ncol-1 个颜色在**非黑像素**上做
加权 k-means(按出现次数加权,梯度才不会被子像素级的近似黑拖偏)。固定迭代次数 => 确定。
"""
src = np.asarray(rgb if rgb.mode == 'RGB' else rgb.convert('RGB'))
return quantize_arr(src, ncol, fixed)
def quantize_arr(src, ncol, fixed, cache=None, ckey=None):
"""量化主体。src 是 (H,W,3) uint8;ckey 用来复用同一帧的颜色表。
性能关键(第一版在这里把机器跑爆过内存 1.7 GB / CPU 卡死):
* 源图有 1.5~2 万个不同颜色、21.5 万个像素。**质心只在"不同颜色"上算**,
绝不在 21.5 万像素上做 (N, K, 3) 的距离广播
(那一步就是 N=215296 x K=255 的模式,一个临时数组 1.6 GB)。
* 像素 -> 索引 走一张**粗查色立方** LUT:先把 RGB 量化到 step 网格
(step=4 时 64³ = 262144 个格子),只对这 26 万个格子算一次最近邻,
再让 21.5 万像素直接查表。代价是"近似最近邻"(最大偏 step/2 = 2 个色阶),
对 8 位调色板完全够用。
"""
H, W = src.shape[:2]
if cache is None:
cache = {}
ent = cache.get(ckey)
if ent is None:
flat = src.reshape(-1, 3)
bg = flat.max(axis=1) == 0
upix, inv = np.unique(flat, axis=0, return_inverse=True)
upix = np.ascontiguousarray(upix, dtype=np.int32)
inv = inv.reshape(-1).astype(np.int32)
fg = np.nonzero(upix.max(axis=1) > 0)[0]
ent = (upix, inv, fg, bg, flat.shape[0])
cache[ckey] = ent
upix, inv, fg, bg, npix = ent
if len(fg) == 0:
return palette_bytes([(0, 0, 0)]) + bytes(npix)
if fixed is not None:
cols = FIXED_PALETTES[fixed]
out = _lut_index(upix, cols)[inv]
out[bg] = 0
# 背景必须落在索引 0(黑),这样 RLE 才是一条单游程
if cols[0] != (0, 0, 0):
raise ValueError('固定调色板的索引 0 必须是黑')
return palette_bytes(cols) + out.tobytes()
k = max(1, min(255, int(ncol) - 1))
ufg = upix[fg]
uq, cnt = np.unique(ufg, axis=0, return_counts=True)
if len(uq) <= k:
centers = uq.astype(np.float64)
else:
centers = uq[cnt.argsort()[::-1][:k]].astype(np.float64) # 出现最多的 k 色做种子
w = cnt.astype(np.float64)
K = len(centers)
for _ in range(8):
lab = _nearest_argmin(uq, centers)
new = centers.copy()
for j in range(K):
sel = lab == j
if sel.any():
ww = w[sel][:, None]
new[j] = (uq[sel] * ww).sum(axis=0) / ww.sum()
done = np.allclose(new, centers, atol=0.25)
centers = new
if done:
break
cols = [(0, 0, 0)] + [tuple(int(round(v)) for v in c) for c in centers]
out = _lut_index(upix, cols)[inv]
out[bg] = 0
return palette_bytes(cols) + out.tobytes()
def _nearest_argmin(px, cent):
"""返回每个 px 最近的 cent 下标;只用 float32(int32 的立方广播会反复申请上百 MB)。"""
a = np.asarray(px, dtype=np.float32)
c = np.asarray(cent, dtype=np.float32)
out = np.empty(len(a), dtype=np.int32)
chunk = 8192
for s in range(0, len(a), chunk):
d = ((a[s:s + chunk, None, :] - c[None, :, :]) ** 2).sum(axis=2)
out[s:s + chunk] = d.argmin(axis=1)
return out
_GRID_CACHE = {}
def _cube_grid(step):
"""粗查色立方的网格代表色(只算一次,全局缓存)。
展平顺序是 idx = r*(bins*bins) + g*bins + b —— 与 `meshgrid(indexing='ij')`
+ `reshape(-1,3)` 完全一致,所以立方体可以直接按这个公式索引。
★ 轴序写错会静默换色:查表必须用 (R,G,B) 顺序,写成 (B,G,R) 颜色就全串了。
"""
g = _GRID_CACHE.get(step)
if g is None:
bins = 256 // step + 1
ax = np.arange(bins, dtype=np.int32) * step
grid = np.stack(np.meshgrid(ax, ax, ax, indexing='ij'), axis=-1).reshape(-1, 3)
_GRID_CACHE[step] = (grid, bins)
return _GRID_CACHE[step]
return g
def _lut_index(px, cols):
"""像素 -> 最近调色板项,走粗查色立方 LUT。
step=8 → 33³ = 35937 个格子,单通道最大偏差 step/2 = 4 个色阶;
对这些低对比度、本来就带抖动的画面完全够用(step=4 的 26 万格子慢 8 倍)。
用 float32 算距离:int32 的 (35937, 256, 3) 中间数组会反复申请 ~100 MB,
是之前把内存跑到 2.6 GB、整体跑十几分钟的元凶。
"""
grid, bins = _cube_grid(8)
pal = np.asarray(cols, dtype=np.float32)
best = np.empty(len(grid), dtype=np.uint8)
gn = grid.astype(np.float32)
chunk = 8192
for s in range(0, len(grid), chunk):
a = gn[s:s + chunk]
d = ((a[:, None, :] - pal[None, :, :]) ** 2).sum(axis=2)
best[s:s + chunk] = d.argmin(axis=1).astype(np.uint8)
q = np.clip((px.astype(np.int32) + 4) >> 3, 0, bins - 1)
flat = (q[:, 0] * bins + q[:, 1]) * bins + q[:, 2]
return best[flat]
def pad_to_usize(comp, raw_len):
"""解码长度必须**正好 == usize**,否则设备端解码器直接判失败、整帧不画。
★ 本会话踩过的坑:库里 rle_compress 的输出曾比 usize 少 1 个字节(末尾少一条指令)。
这里显式补「重复 N 次」token 把长度做满。补出来的余数可能是奇数(原厂 usize 就是
1024+464*464+1 = 216321,比"调色板+像素"多 1),所以最后允许一条「重复 1 次」的
2 字节指令;`00 00`(重复 0 次)也是合法空指令。
"""
short = USIZE - raw_len
if short <= 0:
return comp
ex = bytearray()
while short >= 2:
k = min(short, 127)
ex += bytes((k, 0))
short -= k
if short == 1:
ex += bytes((1, 0))
return comp + bytes(ex)
def encode_cell(img, cell, qcache=None, qkey=None):
"""(缩放, 颜色数, 固定) -> (RLE 流 bytes, 裸像素流 bytes)。不含 24 B 头。
qkey 要带上**缩放比例**:同一帧的不同缩放是不同的数组,
★ 少了这个维度会让"不同颜色的表"永远命中不了缓存,每次都在 21.5 万像素上重算
(第一版就是这样把内存跑到 1.7 GB、CPU 卡死的)。
"""
sc, ncol, fixed = cell
rgb = scale_image(img, sc).convert('RGB')
raw = quantize_arr(np.asarray(rgb), ncol, fixed, qcache,
(qkey, sc) if qkey is not None else None)
return pad_to_usize(L.rle_compress(raw), len(raw)), raw
def raw_to_rgb(raw):
"""把裸像素流还原成 RGB 图(用于"解出来的像素 == 量化结果"这条自证)。"""
pal = bytearray(768)
for i in range(256):
pal[i * 3 + 0] = raw[i * 4 + 2]
pal[i * 3 + 1] = raw[i * 4 + 1]
pal[i * 3 + 2] = raw[i * 4 + 0]
im = Image.frombytes('P', (FACE_W, FACE_H), raw[1024:1024 + FACE_W * FACE_H])
im.putpalette(bytes(pal))
return im.convert('RGB')
def silhouette(raw):
"""(非黑像素数, bbox) —— 用来挡住"字节完全自洽、内容却整片错位"这类 bug。
★ 这里就是因为只比字节、不比形状,差点放过去一版"斜条纹"图:
当时 quantize 是按 472 宽取的像素,读成 464 宽 → 每行错 8 列 → 整幅图被剪成斜条纹。
RLE 流本身完全正确、解出来的像素也和"量化结果"逐点一致,所以只有把
"量化结果"再和**源图**比形状才抓得住。下面这个函数就是干这个的。
"""
a = np.asarray(raw_to_rgb(raw))
m = a.max(axis=2) > 24
n = int(m.sum())
if not n:
return 0, None
ys, xs = np.nonzero(m)
return n, (int(ys.min()), int(xs.min()), int(ys.max()), int(xs.max()))
# ------------------------------------------------------------------ 主流程
def build():
t0 = time.time()
global USIZE, USIZE_EXTRA
os.makedirs(OUTDIR, exist_ok=True)
stock, src = Romfs(TARGET_IMAGE), Romfs(SOURCE_IMAGE)
if stock.volume != TARGET_VOLUME:
raise SystemExit('目标镜像卷名是 %r,期望 %r —— 拿错分区了?'
% (stock.volume, TARGET_VOLUME))
slots, srcs = collect(stock), collect(src)
tl, sl = sorted(slots), sorted(srcs)
h0 = L.parse_header(slots[tl[0]][2])
print('目标槽位 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % (
len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576))
print('源帧 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % (
len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576))
print('槽位格式 %dx%d cf=0x%02x flags=0x%02x stride=%d' % (
h0['w'], h0['h'], h0['cf'], h0['flags'], h0['stride']))
assert (h0['w'], h0['h']) == (FACE_W, FACE_H), '槽位不是 %dx%d' % (FACE_W, FACE_H)
assert (h0['cf'], h0['flags']) == (0x0a, 0x08), '槽位不是 I8 + RLE'
# ★ usize 从镜像里读出来,别写常量:原厂是 1024 + 464*464 + 1 = 216321
n_usize = {struct.unpack('<III', slots[n][2][12:24])[2] for n in tl}
print('原厂 usize 取值集合:%s' % sorted(n_usize))
assert len(n_usize) == 1, '原厂 usize 不唯一:%s' % sorted(n_usize)
USIZE = n_usize.pop()
USIZE_EXTRA = USIZE - (1024 + FACE_W * FACE_H)
print('usize = %d(= 1024 + %d*%d + %d)' % (USIZE, FACE_W, FACE_H, USIZE_EXTRA))
assert USIZE_EXTRA == TAIL_EXTRA, \
'尾部额外字节是 %d,与预期的 %d 不符 —— 先确认原厂流到底多几个字节' % (USIZE_EXTRA, TAIL_EXTRA)
# 原厂 119 条都要真的能解出正好 usize
bad_stock = [n for n in tl
if len(L.rle_decompress(slots[n][2][24:24 + struct.unpack('<III', slots[n][2][12:24])[1]],
1, None)) != USIZE]
print('原厂 %d 条解码长度 == usize:%s' % (len(tl), not bad_stock))
assert not bad_stock, '原厂就有解不出 usize 的槽位:%s' % bad_stock[:8]
# ---- 逐源帧解码 + 缩放 + 暗部归零(100 帧只做一次)----
frames = {}
for n in sl:
_, im = L.decode_lvgl_bin(srcs[n][2])
im = im.convert('RGBA').resize((FACE_W, FACE_H), Image.LANCZOS)
frames[n] = {d: kill_dark(im, d) for d in DARK_LEVELS}
print('解码 + 缩放 %d 帧:%.1fs' % (len(sl), time.time() - t0))
# 映射与 S4e 版一致:第 k 个槽位(k=0..118) <- q63 第 round(k/(119-1)*(100-1))+1 帧
def qn_for(k):
return sl[round(k / (len(tl) - 1) * (len(sl) - 1))]
cells = {} # (src_n, dark, ci) -> (comp, raw)
src_sils = {} # (src_n, dark, ci) -> (非黑像素数, bbox)
qcache = {}
def get_cell(src_n, dark, ci):
key = (src_n, dark, ci)
v = cells.get(key)
if v is None:
v = encode_cell(frames[src_n][dark], LADDER[ci], qcache, (src_n, dark))
cells[key] = v
src_sils[key] = silhouette(v[1])
return v
def fits(src_n, dark, ci, csize):
return len(get_cell(src_n, dark, ci)[0]) <= csize
# ---- 第一遍:逐槽量出「能塞进 csize 的最大阶梯档」----
caps = []
for k, n in enumerate(tl):
csize = struct.unpack('<III', slots[n][2][12:24])[1]
src_n = qn_for(k)
best = None
for dark in DARK_LEVELS:
for ci in range(len(LADDER)):
if fits(src_n, dark, ci, csize):
best = (ci, dark)
break
if best:
break
caps.append(best if best else (CI_KEEP, DARK_LEVELS[0]))
n_keep = sum(1 for c, _d in caps if c == CI_KEEP)
print('第一遍完成:%d/%d 槽位能编出 ≤ csize 的流,%d 个连最省档都不行(耗时 %.0fs)'
% (len(tl) - n_keep, len(tl), n_keep, time.time() - t0))
# ---- 平滑:从峰值向两侧取「运行最小值」----
ordered = [c for c, _d in caps]
peak = ordered.index(max(ordered))
sm = list(ordered)
for i in range(peak - 1, -1, -1):
sm[i] = min(sm[i], sm[i + 1])
for i in range(peak + 1, len(sm)):
sm[i] = min(sm[i], sm[i - 1])
jumps = [abs(sm[i + 1] - sm[i]) for i in range(len(sm) - 1)]
print('阶梯曲线(每 6 帧采样):%s' % ' '.join(str(sm[i]) for i in range(0, len(sm), 6)))
print('相邻帧最大跳变:%d 档' % (max(jumps) if jumps else 0))
# ---- 第二遍:以平滑档位为目标,在预算里挑**画面最大**的那一档;塞不下就往更省的档退 ----
# 「挑画面最大」而不是「挑第一个能塞下的」:平滑只限制相邻帧的尺寸变化幅度,
# 不该让它浪费槽位。实测按"第一个能塞下的"走,尾部几帧会把 7~12 万字节的 csize
# 只用掉一半(全填 00 00 空指令),球体白白小一圈。
records, degrade, kept, decode_ok, stat = [], [], [], 0, []
for k, n in enumerate(tl):
path, ino, blob = slots[n]
method, csize, usize = struct.unpack('<III', blob[12:24])
src_n = qn_for(k)
start = sm[k]
# 先按档位从优到劣扫一遍,记下所有塞得下的格子,再选缩放最大/颜色最多的那个
best = None
if start < CI_KEEP:
for ci in range(start, len(LADDER)):
for dark in DARK_LEVELS:
if fits(src_n, dark, ci, csize):
# 平滑约束:缩放不能比目标档大太多(否则相邻帧会忽大忽小),
# 但可以更小;颜色数不受约束(只影响色阶,不影响尺寸)
if LADDER[ci][0] <= LADDER[start][0] + SCALE_SLACK:
sc_i, ncol_i, fx_i = LADDER[ci]
key = (sc_i, ncol_i)
if best is None or key > best[1]:
best = ((ci, dark), key)
rec_name = '%03d_%s_%d' % (len(records), path.rsplit('/', 1)[-1][:-4], n)
if best is None:
kept.append((n, csize))
records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off,
length=len(blob), data=blob, csize=csize, how='保留原厂',
ci=None, dark=None, scale=1.0, colors=0, pad=0, enc=0))
print(' !! 槽 %d(csize %d)塞不下 -> 保留原厂' % (n, csize))
continue
(ci, dark), _key = best
comp, raw = get_cell(src_n, dark, ci)
new = blob[:12] + struct.pack('<III', method, csize, usize) + L.pad_rle_safe(comp, csize)
assert len(new) == len(blob), (len(new), len(blob))
# ★ 就地自证三道:
# ① 解出来正好 usize 字节(少一字节设备端就整帧不画)
# ② 解出来的像素 == 量化结果(逐点)
# ③ 量化结果的**形状**和源图在同一量级(挡住"字节自洽但内容错位"的 bug)
# 第③条的容差:低色阶档位下球体边缘的暗像素会被并到最暗的那一档、显得略小
# (4 色/2 色时尤其明显),所以只要求非黑像素数在 ±6% 内、bbox 每边差 ≤12 像素;
# 真正"斜条纹"那种错位会让 bbox 直接铺满全画幅,这条一定抓得住。
back = L.rle_decompress(new[24:24 + csize], 1, None)
_, dec = L.decode_lvgl_bin(new)
same = dec is not None and dec.convert('RGB').tobytes() == raw_to_rgb(raw).tobytes()
src_sil = src_sils[(src_n, dark, ci)]
got_sil = silhouette(raw)
sil_ok = (src_sil[1] is not None and got_sil[1] is not None and
abs(src_sil[0] - got_sil[0]) <= 0.06 * max(1, src_sil[0]) and
max(abs(a - b) for a, b in zip(src_sil[1], got_sil[1])) <= 12)
if len(back) == usize and same and sil_ok:
decode_ok += 1
else:
print(' !! 槽 %d:解码 %d 字节(应 %d),像素一致=%s,形状合理=%s(%s vs %s)'
% (n, len(back), usize, same, sil_ok, src_sil, got_sil))
stat.append((n, csize, len(comp), csize - len(comp)))
sc, ncol, fixed = LADDER[ci]
how = ('固定%d色' % fixed if fixed is not None else '%d色' % ncol)
how += '' if sc >= 0.995 else ' 缩放%.2f' % sc
how += ' 暗部<%d' % dark
if ci > start:
degrade.append((n, csize, start, ci, how))
how += ' [平滑目标第%d档放不下,退到第%d档]' % (start, ci)
records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off,
length=len(blob), data=new, csize=csize, how=how, ci=ci,
dark=dark, scale=round(sc, 4), colors=ncol, fixed=fixed,
pad=csize - len(comp), enc=len(comp), target_ci=start))
# ---- 写出镜像 + 清单 ----
shutil.copyfile(TARGET_IMAGE, PATCHED)
with open(PATCHED, 'r+b') as f:
for r in records:
f.seek(r['offset'])
f.write(r['data'])
manifest = dict(
device='Xiaomi Watch S5 eSIM 46mm (p62lte)',
partition='vela_health.bin volume=health device node=/dev/health',
source='Xiaomi Watch S5 41mm (q63, v4.101.020) pressure/measure frames',
target_dir=DIRP,
mapping='第 k 个槽位(k=1..%d) <- q63 第 round((k-1)/(%d-1)*(%d-1))+1 帧(1..%d)'
% (len(tl), len(tl), len(sl), len(sl)),
face_wh=[FACE_W, FACE_H], slot_count=len(tl), source_count=len(sl), usize=USIZE,
image_sha256=hashlib.sha256(stock.data).hexdigest(),
ladder=[dict(ci=i, scale=c[0], colors=c[1], fixed=c[2]) for i, c in enumerate(LADDER)],
records=[dict(name=r['name'], offset=r['offset'], length=r['length'],
src=r['src'], how=r['how'], ci=r['ci'], dark=r['dark'],
scale=r['scale'], colors=r['colors'], fixed=r['fixed'],
pad=r['pad'], enc=r['enc'], target_ci=r['target_ci'],
csize=r['csize']) for r in records],
kept_stock=[dict(n=n, csize=c, name=slots[n][0]) for n, c in kept],
degraded=[dict(n=n, csize=c, from_ci=a, to_ci=b, how=h) for n, c, a, b, h in degrade])
json.dump(manifest, open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'),
indent=1, ensure_ascii=False)
# ---- 汇总 ----
print('\n阶梯使用情况(颜色数, 缩放, 暗部 -> 帧数):')
used = {}
for r in records:
if r['ci'] is None:
continue
k2 = (r['colors'], r['scale'], r['dark'])
used[k2] = used.get(k2, 0) + 1
for kk, v in sorted(used.items(), key=lambda kv: (-kv[0][1], -kv[0][0])):
print(' %3d 色 缩放%.2f 暗部<%d -> %3d 帧' % (kk[0], kk[1], kk[2], v))
print(' 保留原厂:%d 帧 %s' % (len(kept), [n for n, _ in kept]))
print(' 平滑目标档放不下、被迫退档:%d 帧 %s'
% (len(degrade), [(n, h) for n, _c, _a, _b, h in degrade][:8]))
pads = [p[3] for p in stat]
print(' 槽位填充:实际用掉 %.1f%% 的 csize(填充 min %d / max %d / 平均 %.0f B,'
'全是合法空指令 00 00 / 80)'
% (100.0 * sum(p[2] for p in stat) / sum(p[1] for p in stat),
min(pads), max(pads), sum(pads) / len(pads)))
print(' 回解自证:正好 usize 且像素一致 %d / %d' % (decode_ok, len(records) - len(kept)))
# ---- 独立自证 ----
r2 = Romfs(PATCHED)
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in r2.entries()]
print('\ninode 表逐位不变:%s(%d 条)' % (a == b, len(a)))
print('superblock 一致:%s(size %d / cksum %08x)' % (
stock.sb_size == r2.sb_size and stock.sb_cksum == r2.sb_cksum,
r2.sb_size, r2.sb_cksum))
print('镜像长度一致:%s(%d B)' % (len(stock.data) == len(r2.data), len(r2.data)))
sh = open(PATCHED, 'rb').read()
replay = all(sh[r['offset']:r['offset'] + r['length']] == r['data'] for r in records)
print('回放(改后镜像 == 计划字节):%s' % replay)
lens_ok = all(r['length'] == len(slots[int(r['name'].rsplit('_', 1)[1])][2]) for r in records)
print('每条负载长度 == 原槽位长度:%s' % lens_ok)
print('\n负载合计 %.2f MB;耗时 %.0fs;写出 %s'
% (sum(r['length'] for r in records) / 1048576, time.time() - t0,
os.path.join(OUTDIR, 'manifest.json')))
return manifest
if __name__ == '__main__':
only = sys.argv[1] if len(sys.argv) > 1 else 'all'
if only not in ('all', 'replace', 'restore'):
raise SystemExit('用法: s5e_anim.py [all|replace|restore]')
build()
+312
View File
@@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
"""两遍构建:表盘源码 + 资源 -> dist/S5ePAnim.face / dist/S5ePAnimRestore.face
main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符
.work/overlay/<NAME> <- 中间产物(回填后的 Lua 落在这里)
.work/build/<NAME> <- 编译工作区(Compiler.exe 的输入)
dist/<NAME>.face <- 交付物
为什么两遍
----------
.face 里各资源的偏移取决于打包顺序;我们靠在 .face 里**搜字节**来定位它们。
Lua 源码排在所有资源之后,所以回填(Lua 变长)不会移动前面的资源 —— 脚本最后逐条校验 0 漂移。
为什么最终产物要"再裸编一次"
----------------------------
两遍构建走的是 vendor 的 scripts/build_face.ps1,它在编译后调 set_face_id.ps1,
把**头里第 5 字节写成 id 长度 10**,而固件自带表盘的 b5 是 0。
所以交付物改从裸 `Compiler.exe -b` 的输出取(b5=0),只覆盖 40..49 = id。
断言:裸产物与 toolchain 产物必须"只差 b5 + id 这 8 个字节",否则直接拒绝交付。
两份盘共用同一个 watchface id(462150102):装一份就顶掉另一份,设备上只占一份空间。
"""
import os, re, sys, json, shutil, hashlib, struct, subprocess
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import PROJ, WORK, DIST, TEMPLATE, WF_ID, SCREEN, DEVICE_TYPE, PREVIEW
import s5e_overlay as OV
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
assert ROLE in ('replace', 'restore'), ROLE
NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' # 路径/标识(不要改)
TITLE = 'S5e Pt.2' # 手表上显示的表盘名(replace / restore 两张盘同名,须与 s5e_overlay.TITLE 一致)
RECDIR = 'payload' if ROLE == 'replace' else 'payload_restore'
TOOLS = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(PROJ, 'main.lua')
OVER = os.path.join(WORK, 'overlay', NAME)
BUILD = os.path.join(WORK, 'build', NAME)
RESOLVED = os.path.join(OVER, 'watchface', 'fprj', 'app', 'lua', 'main.lua')
LOGLINES = []
def log(s):
print(s)
LOGLINES.append(s)
def write_lua(lua_text):
"""把 Lua 写进编译工作区(两遍构建的唯一区别就在这里)。"""
p = os.path.join(BUILD, 'watchface', 'fprj', 'app', 'lua', 'main.lua')
os.makedirs(os.path.dirname(p), exist_ok=True)
open(p, 'w', encoding='utf-8', newline='\n').write(lua_text)
def prepare(lua_text, role):
"""把官方模板拷成编译工作区,写 config / fprj / preview / lua / 资源。"""
if not os.path.exists(TEMPLATE):
raise SystemExit('找不到官方模板:%s\n请把 LuaDevTemplate-main 放到 vendor/ 下' % TEMPLATE)
if os.path.exists(BUILD):
shutil.rmtree(BUILD)
shutil.copytree(TEMPLATE, BUILD)
old = os.path.join(BUILD, 'watchface', 'fprj', 'LuaDevTemplate.fprj')
if os.path.exists(old):
os.remove(old)
cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3",
"resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE",
"input": "watchface/fprj/images/preview.png", "name": "preview"}}
json.dump(cfg, open(os.path.join(BUILD, 'watchface.config.json'), 'w', encoding='utf-8'),
indent=4, ensure_ascii=False)
fprj = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
'<FaceProject DeviceType="%s">\r\n'
' <Screen Title="%s" Bitmap="preview.png">\r\n'
' <Widget Shape="34" Name="app_lua%%2Fmain.lua" X="0" Y="0" '
'Width="%d" Height="%d" Alpha="0" />\r\n'
' </Screen>\r\n</FaceProject>\r\n') % (DEVICE_TYPE, TITLE, SCREEN, SCREEN)
open(os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj'), 'w',
encoding='utf-16', newline='').write(fprj)
# 预览图 = 真实首页渲染 -> 圆形 -> 326x326(Compiler.exe 对 462 只收这个尺寸)
prev = os.path.join(OVER, 'watchface', 'fprj', 'images', 'preview.png')
OV.make_preview(prev)
shutil.copyfile(prev, os.path.join(BUILD, 'watchface', 'fprj', 'images', 'preview.png'))
write_lua(lua_text)
imgdir = os.path.join(BUILD, 'watchface', 'fprj', 'app', 'images')
for sub, src in (('pay', os.path.join(PROJ, 'blob_' + role)),
('ui', os.path.join(PROJ, 'ui_images'))):
if not os.path.isdir(src):
raise SystemExit('缺少 %s/ —— 先跑 tools/s5e_payload.py %s' % (src, role))
dst = os.path.join(imgdir, sub)
if os.path.exists(dst):
shutil.rmtree(dst)
os.makedirs(dst)
for fn in sorted(os.listdir(src)):
shutil.copyfile(os.path.join(src, fn), os.path.join(dst, fn))
def run_build():
ps = os.path.join(BUILD, 'scripts', 'build_face.ps1')
# 显式给 -FaceName:vendor 脚本默认用 config 的 projectName 当输出名,而 projectName 现在
# 是**显示名**(S5e Pt.2),交付文件名必须还是 NAME.face。
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps,
'-FaceName', NAME + '.face'],
capture_output=True, text=True, errors='replace')
workspace_fprj() # vendor 可能把 .fprj 改名成显示名,编完改回 NAME.fprj
out = ((r.stdout or '') + (r.stderr or '')).splitlines()
for l in [x for x in out if x.strip()][-4:]:
log(' | ' + l)
face = os.path.join(BUILD, 'bin', NAME + '.face')
if not os.path.exists(face):
raise SystemExit('编译失败:没有产出 %s' % face)
return open(face, 'rb').read()
def workspace_fprj():
"""把工作区里的 .fprj 归一成 NAME.fprj,并返回它的路径。
我们写下去时就是 NAME.fprj,但 vendor 的 build_face.ps1 会先跑
sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S5e Pt.2)找
<projectName>.fprj,找不到就把唯一那份改名过去(Title 也被改写成 projectName)。
编完这里再改回 NAME.fprj —— 工作区里始终只有一份 .fprj(放两份会被 Compiler.exe 并成
双倍资源,实测资源整体翻倍),文件名也保持项目标识不变。
"""
d = os.path.join(BUILD, 'watchface', 'fprj')
fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj'))
if len(fs) != 1:
raise SystemExit('watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % fs)
want = os.path.join(d, NAME + '.fprj')
if fs[0] != NAME + '.fprj':
os.replace(os.path.join(d, fs[0]), want)
return want
def compile_raw():
"""裸 Compiler.exe —— 拿**忠实产物**(不经 vendor 的 set_face_id.ps1 改头)。
★ 为什么必须多这一步(b5=10 的根因)
run_build() 走的是 vendor 的 build_face.ps1,它编完之后会调
scripts/internal/set_face_id.ps1,那个脚本里有一句
$bytes[5] = [byte]$faceIdSize # = 10
也就是把「id 字符串长度」写进了头里第 5 字节。固件自带表盘的 b5 是 0,
交付物照着 0 走;所以最终产物必须取自**裸编译输出**(b5 由编译器给,实测 0),
然后只把 40..49 覆盖成我们的 id,b5 一个字节都不碰。
(s5e-icons 的 s5e_build.py 就是 compile_raw() + 补 id 这一套,真机验证过。)
"""
exe = os.path.join(BUILD, 'watchface', 'tools', 'Compiler.exe')
fprj = workspace_fprj()
out = os.path.join(BUILD, 'raw')
if os.path.exists(out):
shutil.rmtree(out)
os.makedirs(out)
r = subprocess.run([exe, '-b', fprj, out, NAME + '.face', WF_ID],
capture_output=True, text=True, errors='replace')
p = os.path.join(out, NAME + '.face')
if not os.path.exists(p):
raise SystemExit('裸编译失败:%s' % ((r.stdout or '') + (r.stderr or ''))[-400:])
return open(p, 'rb').read()
def hits(face, blob):
out, s = [], 0
while True:
i = face.find(blob, s)
if i < 0:
return out
out.append(i)
s = i + 1
UI_NAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback',
'ic_qq', 'logo']
def locate(face, blobs, pay_blob, fp_blob):
"""量出 UI 图片 / 负载 / 指纹在 .face 里的偏移,并逐条字节校验。"""
ui, bad = [], 0
for name in UI_NAMES:
blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read()
h = hits(face, blob)
if len(h) != 1:
log(' !! UI %s 命中 %d 次(应为 1)' % (name, len(h)))
bad += 1
if h:
ui.append((h[0], len(blob), name))
if bad:
raise SystemExit('有 UI 资源无法唯一定位')
ml, mlf = blobs['magic_len'], blobs['magic_fp_len']
if face.count(pay_blob[:ml]) != 1:
raise SystemExit('payload 魔数命中 %d 次(应为 1)' % face.count(pay_blob[:ml]))
if face.count(fp_blob[:mlf]) != 1:
raise SystemExit('fp 魔数命中 %d 次(应为 1)' % face.count(fp_blob[:mlf]))
pay_base = face.find(pay_blob)
fp_base = face.find(fp_blob)
if pay_base < 0:
raise SystemExit('payload 不是原样连续存放')
if fp_base < 0:
raise SystemExit('fp 不是原样连续存放')
# 用生成负载时的 sha256 逐条独立校验(避免"自己比自己"这种恒真检查)
for tag, base, path, key, mag in (
('pay', pay_base, os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'),
'pay_sha', ml),
('fp', fp_base, os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'fp_sha', mlf)):
data = open(path, 'rb').read()
if face.find(data) != base:
raise SystemExit('%s 文件不是原样连续存放' % tag)
for i, r in enumerate(blobs['records']):
res = base + mag + (r['rel'] if tag == 'pay' else r['fprel'])
ln = r['length'] if tag == 'pay' else r['fplen']
seg = face[res:res + ln]
if len(seg) != ln or hashlib.sha256(seg).hexdigest() != r[key]:
raise SystemExit('%s[%d] 在 0x%x 处字节不吻合' % (tag, i, res))
pay = [(pay_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']]
rb = [(fp_base + mlf + r['fprel'], r['target'], r['fplen']) for r in blobs['records']]
return ui, pay, rb, pay_base, fp_base
def main():
tpl = open(SRC, encoding='utf-8').read()
for ph in ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__'):
if ph not in tpl:
raise SystemExit('main.lua 里没有占位符 %s —— 它应该是源码模板' % ph)
log('=== %s(role=%s,id=%s)===' % (NAME, ROLE, WF_ID))
blobs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'),
encoding='utf-8'))
for r in blobs['records']:
if 'pay_sha' not in r or 'fp_sha' not in r:
raise SystemExit('offsets.json 里没有 sha256 —— 请重跑 tools/s5e_payload.py %s' % ROLE)
pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read()
fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read()
ml, mlf = blobs['magic_len'], blobs['magic_fp_len']
log('第 1 遍:用占位符编一次,量出资源在 .face 里的位置')
prepare(tpl, ROLE)
face1 = run_build()
log(' face %d bytes' % len(face1))
ui, pay, rb, pay_base, fp_base = locate(face1, blobs, pay_blob, fp_blob)
for o, l, n in ui:
log(' %-12s off=0x%06x len=%d' % (n, o, l))
log(' payload base=0x%06x(%d B) / fp base=0x%06x(%d B),共 %d 条记录'
% (pay_base, len(pay_blob), fp_base, len(fp_blob), len(pay)))
ui_txt = '\n'.join(' {%d,%d,"%s"},' % (o, l, n) for o, l, n in ui)
pay_txt = ' pay = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in pay) + '\n },'
rb_txt = ' rb = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in rb) + '\n },'
resolved = (tpl.replace('__UIIMG__', ui_txt).replace('__PAY__', pay_txt)
.replace('__RB__', rb_txt).replace('__ROLE__', ROLE))
assert '__UIIMG__' not in resolved and '__ROLE__' not in resolved
assert '__PAY__' not in resolved and '__RB__' not in resolved
os.makedirs(os.path.dirname(RESOLVED), exist_ok=True)
open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved)
log('第 2 遍:回填后的 Lua(%d bytes)重新编译' % len(resolved))
# ★ 第 1 遍编的是**占位符版**;第 2 遍必须把回填后的 Lua 写回编译工作区再编,
# 否则交付物里还是 __PAY__/__RB__/__ROLE__(表盘在设备上直接"负载表为空")。
write_lua(resolved)
face2 = run_build()
# ---- 0 漂移校验 ----
drift = 0
ui2, pay2, rb2, pb2, fb2 = locate(face2, blobs, pay_blob, fp_blob)
if ui2 != ui:
log(' !! UI 偏移漂移'); drift += 1
if (pb2, fb2) != (pay_base, fp_base):
log(' !! 负载偏移漂移'); drift += 1
if (pay2, rb2) != (pay, rb):
log(' !! 负载表内容漂移'); drift += 1
files = [(f, open(os.path.join(PROJ, RECDIR, f), 'rb').read())
for f in sorted(os.listdir(os.path.join(PROJ, RECDIR))) if f.endswith('.bin')]
ok = sum(1 for off, tgt, ln in pay
if any(len(x) == ln and face2[off:off + ln] == x for _n, x in files))
if ok != len(pay):
log(' !! pay 只有 %d/%d 条与 %s/ 吻合' % (ok, len(pay), RECDIR)); drift += 1
log('偏移校验:%s' % ('全部吻合,0 漂移' if drift == 0 else '!! %d 处异常' % drift))
# ---- 最终产物:id 必须逐字节正确(b5=0,40..49 = id + NUL)----
# 交付物取**裸编译**的忠实产物:b5 保持编译器给的值(实测 0),只覆盖 id 那 10 字节。
# (vendor 的 build_face.ps1 会顺手把 b5 写成 id 长度 10,不能拿它当交付物。)
raw = compile_raw()
if raw[0:4] != b'\x5a\xa5\x34\x12':
raise SystemExit('bad face magic')
nd = [i for i in range(min(len(raw), len(face2))) if raw[i] != face2[i]]
stray = [i for i in nd if not (i == 5 or 40 <= i < 50)]
if len(raw) != len(face2) or stray:
raise SystemExit('忠实产物与 toolchain 产物不是"只差 b5 + id":'
'len %d vs %d,差异 %d 字节,越界位置 %s'
% (len(raw), len(face2), len(nd), stray[:8]))
if raw[5] != 0:
raise SystemExit('裸编译产物的 b5 = %d(期望 0)—— 设备表盘的约定变了,得重查' % raw[5])
d = bytearray(raw)
d[40:50] = WF_ID.encode() + b'\x00' * (10 - len(WF_ID))
os.makedirs(DIST, exist_ok=True)
out = os.path.join(DIST, NAME + '.face')
open(out, 'wb').write(bytes(d))
a32, = struct.unpack('<I', bytes(d[32:36]))
pal, pw, ph, psz = struct.unpack('<IHHI', bytes(d[a32:a32 + 12]))
log('忠实产物 vs toolchain:%d 字节差异(只允许 b5 + id 这 8 个位置:%s)'
% (len(nd), nd))
log('b4=%d b5=%d id=%r' % (d[4], d[5], bytes(d[40:50])))
log('preview @0x%x: %dx%d palette=%d data=%d' % (a32, pw, ph, pal, psz))
if (pw, ph) != (PREVIEW, PREVIEW):
raise SystemExit('预览块尺寸 %dx%d 不是 %dx%d' % (pw, ph, PREVIEW, PREVIEW))
if d[5] != 0 or bytes(d[40:50]) != WF_ID.encode() + b'\x00' * (10 - len(WF_ID)):
raise SystemExit('交付物头不对:b5=%d id=%r' % (d[5], bytes(d[40:50])))
log('交付物:%s %d B sha256 %s' % (out, len(d), hashlib.sha256(bytes(d)).hexdigest()))
if drift:
raise SystemExit('有偏移漂移,拒绝交付')
open(os.path.join(WORK, 'build_log_%s.txt' % ROLE), 'w',
encoding='utf-8').write('\n'.join(LOGLINES))
if __name__ == '__main__':
main()
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""圆屏切边检查:用**真实渲染出来的像素**判 480x480 圆屏有没有被切到。
圆心 (240,240)、半径 240。任何 alpha>8 且离圆心 >240 的像素都算越界。
"""
import os, sys, math
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import s5e_render_ui as R
from s5e_pages import PAGES
W = H = 480
CX = CY = 240.0
RAD = 240.0
print('%-10s %8s %11s worst point' % ('page', 'max r', 'outside px'))
print('-' * 62)
bad = 0
for name, items in PAGES.items():
im = R.render_raw(items)
px = im.load()
worst, wpt, outside = 0.0, None, 0
for y in range(H):
for x in range(W):
if px[x, y][3] > 8:
dd = math.hypot(x - CX, y - CY)
if dd > worst:
worst, wpt = dd, (x, y)
if dd > RAD:
outside += 1
print('%-10s %8.1f %11d %s%s' % (name, worst, outside, wpt,
'' if worst <= RAD else ' <-- CLIPPED'))
if outside:
bad += 1
print('-' * 62)
print('圆心 (240,240) 半径 240 画布 480x480 越界页数:%d' % bad)
if R.OVERFLOW:
print('!! 文本溢出框 %d 处:' % len(R.OVERFLOW))
for o in R.OVERFLOW:
print(' ', o)
sys.exit(1 if (bad or R.OVERFLOW) else 0)
+145
View File
@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""main.lua 的静态体检 —— 编译前把「真机上才会炸」的几类错抓出来。
S4e 那版的真机事故是:`segs/ddWrite/ddRead` 被放在 `slurp/sh` **之前**,
于是函数体里引用的 `sh` 不是那个 local,而是**全局 nil**,一调用就
`attempt to call a nil value (global 'sh')` —— 而且当时整条流水线全绿
(Compiler.exe 只打包 Lua、不解析它)。所以本文件专门查:
① 块配平(function/if/for/while/do/end/repeat/until)
② **先用后声明**:某个名字在文件里是 `local`,但第一次调用发生在它声明之前
③ 调用了既不是 local、也不在 Lua 标准库白名单里的名字(拼写错 / 忘了 local)
④ 模板约定:4 个占位符各 1 次、`--[[` 只在文件头、以 `return ui\\n` 结尾
(tools/s5e_verify.py 会从 .face 里反解 Lua,这几条是它定位 Lua 的前提)
用法:python tools/s5e_lua_static.py [main.lua]
"""
import os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import PROJ
from lua_balance import strip
LUAC = os.path.join(PROJ, 'main.lua')
# Lua 5.4 标准库 + 本项目用到的宿主全局(都不是拼写错)
WHITELIST = set("""
require pcall xpcall type pairs ipairs next select tostring tonumber rawget rawset
rawequal rawlen setmetatable getmetatable error assert print unpack load
string table math io os coroutine utf8 debug collectgarbage
""".split())
KEYWORDS = set("""
and break do else elseif end false for function goto if in local nil not or
repeat return then true until while
""".split())
REQUIRED_PH = ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__')
fail = 0
def bad(msg):
global fail
fail += 1
print(' FAIL ' + msg)
def ck(cond, msg):
print((' OK ' if cond else ' FAIL ') + msg)
if not cond:
global fail
fail += 1
def lineno(src, idx):
return src.count('\n', 0, idx) + 1
def decls(src, code):
"""名字 -> 它在 code 里第一次被 local 声明(或作为函数参数出现)的位置。"""
out = {}
for m in re.finditer(r'\blocal\s+function\s+([A-Za-z_]\w*)', code):
out.setdefault(m.group(1), m.start(1))
for m in re.finditer(r'\blocal\s+([A-Za-z_][\w,\s]*?)\s*(?:=|$)', code, re.M):
base = m.start(1)
for piece in m.group(1).split(','):
nm = piece.strip()
if re.fullmatch(r'[A-Za-z_]\w*', nm):
out.setdefault(nm, base)
# 函数参数也是 local(`function(a, b)` / `function f(a)`),否则会误报
for m in re.finditer(r'\bfunction\b\s*[A-Za-z_.:]*\s*\(([^)]*)\)', code):
for piece in m.group(1).split(','):
nm = piece.strip()
if re.fullmatch(r'[A-Za-z_]\w*', nm):
out.setdefault(nm, m.start())
return out
def calls(code):
"""所有函数调用点(排除 `function NAME(` 这种定义)。"""
out = []
for m in re.finditer(r'(?<![\w.:])([A-Za-z_]\w*)\s*\(', code):
head = code[max(0, m.start(1) - 12):m.start(1)]
if re.search(r'\bfunction\s+$', head):
continue # 定义,不是调用
out.append((m.group(1), m.start(1)))
return out
def main():
src = open(LUAC, encoding='utf-8').read()
code = strip(src)
print('=== tools/s5e_lua_static.py %s (%d B) ===' % (LUAC, len(src.encode('utf-8'))))
print('\n[1] 块配平')
def cnt(k):
return len(re.findall(r'(?<![\w.])' + k + r'(?![\w])', code))
do_standalone = cnt('do') - cnt('while') - cnt('for')
openers = cnt('function') + cnt('if') + cnt('while') + cnt('for') + do_standalone
ck(openers == cnt('end') and cnt('repeat') == cnt('until'),
'openers=%d end=%d(function=%d if=%d for=%d while=%d do=%d)repeat=%d until=%d'
% (openers, cnt('end'), cnt('function'), cnt('if'), cnt('for'), cnt('while'),
do_standalone, cnt('repeat'), cnt('until')))
print('\n[2] 先用后声明(真机上会变成 "call a nil value")')
d = decls(src, code)
early, unknown = [], []
for name, pos in calls(code):
if name in KEYWORDS or name in WHITELIST:
continue
if name in d:
if pos < d[name]:
early.append((name, lineno(src, pos), lineno(src, d[name])))
else:
unknown.append((name, lineno(src, pos)))
ck(not early, '没有"先调用后 local":%s'
% ('' if not early else ' / '.join('%s 调用@%d 声明@%d' % e for e in early)))
ck(not unknown, '没有未声明的被调用名:%s'
% ('' if not unknown else ' / '.join('%s@%d' % u for u in unknown)))
print('\n[3] 模板约定(s5e_verify.py 靠这些从 .face 里反解 Lua)')
for ph in REQUIRED_PH:
ck(src.count(ph) == 1, '占位符 %s 出现 %d 次(要 1)' % (ph, src.count(ph)))
ck(src.startswith('--[[--'), '文件以 --[[-- 开头')
ck(src.count('--[[') == 1, '--[[ 只出现 %d 次(要 1,否则 verify 会从中间截 Lua)'
% src.count('--[['))
ck(src.endswith('return ui\n'), '以 return ui\\n 结尾')
ck(src.count('return ui\n') == 1, 'return ui\\n 只出现 %d 次(要 1)' % src.count('return ui\n'))
ck('local UIIMG = {' in src, '有 local UIIMG = {')
print('\n[4] 与 s5e_verify.py 对齐的字面量')
ck(re.search(r"local ROLE = '__ROLE__'", src) is not None, "local ROLE = '__ROLE__'")
ck(re.search(r'local W, H = 480, 480', src) is not None, 'local W, H = 480, 480')
m = re.search(r'local DEVS = \{([^}]*)\}', src)
ck(m is not None and '/dev/health' in m.group(1),
'DEVS 里有 health -> /dev/health:%s' % (m.group(1).strip() if m else None))
ck("local DEV = DEVS.health" in src,
'DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设)')
paytbl = re.search(r'local PAYTBL = \{\n__PAY__\n__RB__\n\}', src)
ck(paytbl is not None, 'PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名)')
ck('466' not in src, '没有残留的 466(旧 S4e 尺寸)')
print('\n=== %s ===' % ('全部通过' if not fail else '%d 项失败' % fail))
return 1 if fail else 0
if __name__ == '__main__':
sys.exit(main())
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""为官方 LuaDevTemplate 生成可编译的 overlay。
两份盘(role):
replace -> S5ePAnim 负载 = q63 的帧
restore -> S5ePAnimRestore 负载 = 原厂字节
两份盘**共用同一个 watchface id**(462150102):装一份就顶掉另一份,设备上只占一份空间。
要点(都是踩过的坑):
* preview.png 必须是 326x326 —— Compiler.exe 对 DeviceType 462 只收这个尺寸
* fprj 必须是 UTF-16(带 BOM)且用 CRLF,否则 Compiler.exe 直接拒绝
* 表盘界面是 480x480(Screen Width/Height),预览图只是表盘列表里的缩略图
* 负载只放 blob_<role>/ 那一份「合并后的单条目」
"""
import os, sys, json, shutil
from PIL import Image
from paths import PROJ, WORK, SCREEN, PREVIEW, WF_ID, DEVICE_TYPE
from s5e_pages import PAGES
import s5e_render_ui as R
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
assert ROLE in ('replace', 'restore'), ROLE
NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' # 路径/标识(不要改)
TITLE = 'S5e Pt.2' # 手表上显示的表盘名(两份盘同一个名字)= fprj 的 <Screen Title>
OUT = os.path.join(WORK, 'overlay')
ENTRY_WIDGET = 'app_lua%2Fmain.lua'
def make_preview(path):
"""480 首页 -> 圆形 alpha -> 326x326(Compiler.exe 只收这个尺寸)。"""
R.W = R.H = SCREEN
img = R.render_raw(PAGES['home'])
base = Image.new('RGBA', (SCREEN, SCREEN), (0, 0, 0, 255))
base.alpha_composite(img)
mask = Image.new('L', (SCREEN, SCREEN), 0)
from PIL import ImageDraw
ImageDraw.Draw(mask).ellipse([0, 0, SCREEN - 1, SCREEN - 1], fill=255)
base.putalpha(mask)
os.makedirs(os.path.dirname(path), exist_ok=True)
base.resize((PREVIEW, PREVIEW), Image.LANCZOS).save(path)
def write_fprj(path, title):
xml = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
'<FaceProject DeviceType="%s">\r\n'
' <Screen Title="%s" Bitmap="preview.png">\r\n'
' <Widget Shape="34" Name="%s" X="0" Y="0" '
'Width="%d" Height="%d" Alpha="0" />\r\n'
' </Screen>\r\n</FaceProject>\r\n'
% (DEVICE_TYPE, title, ENTRY_WIDGET, SCREEN, SCREEN))
open(path, 'w', encoding='utf-16', newline='').write(xml)
def build(name, role):
base = os.path.join(OUT, name)
if os.path.exists(base):
shutil.rmtree(base)
fprj = os.path.join(base, 'watchface', 'fprj')
os.makedirs(os.path.join(fprj, 'images'), exist_ok=True)
os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True)
cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3",
"resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE",
"input": "watchface/fprj/images/preview.png", "name": "preview"}}
json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'),
indent=4, ensure_ascii=False)
write_fprj(os.path.join(fprj, name + '.fprj'), TITLE)
make_preview(os.path.join(fprj, 'images', 'preview.png'))
shutil.copyfile(os.path.join(PROJ, 'main.lua'),
os.path.join(fprj, 'app', 'lua', 'main.lua'))
blob = os.path.join(PROJ, 'blob_' + role)
if not os.path.isdir(blob):
raise SystemExit('缺少 blob_%s/ —— 先跑 tools/s5e_payload.py %s' % (role, role))
for src, rel in ((blob, 'pay'), (os.path.join(PROJ, 'ui_images'), 'ui')):
dst = os.path.join(fprj, 'app', 'images', rel)
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(src, dst)
return base
if __name__ == '__main__':
build(NAME, ROLE)
print('overlay written to', os.path.join(OUT, NAME))
+150
View File
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""Xiaomi Watch S5 eSIM (p62lte) 480x480 页面规格。
坐标系就是 `main.lua` 用的那一套;`s5e_render_ui.py` / `s5e_check_clip.py` 画的是同一份规格,
所以预览图是"诚实"的(不是另画一套)。与 S5 图标包那份 480 设计稿同一套语言,
只是页面文案改成压力动画替换器。
"""
import os
from paths import PROJ, DOCS, ASSETS
W = H = 480
BG = (0, 0, 0)
GRAY = '999999'
WHITE = 'ffffff'
PLATE = '002927'
CIRCLE = '4f4f4f'
OK = '43cf7c'
ERR = 'd43030'
S600, S500, S400 = 'semibold', 'medium', 'regular'
# 列表项几何
# radius=40 不是审美选择:y=88 那一行离圆心 152px,圆屏在那里的弦半宽只有 √(240²-152²)=185.8,
# 也就是该行 x 只能在 54.2..425.8 之内。底板是 x=50 w=379,圆角不够大时四个角会越过圆边
# (r=35 时角点半径 240.7 → check_clip 记为越界)。r=40 正好把角收进去。
ITEM_X, ITEM_W, ITEM_H, ITEM_R = 50, 379, 91, 40
ICONC_DX, ICONC_DY, ICONC_SZ = 21, 20, 52
ICON_DX, ICON_DY, ICON_SZ = 31, 30, 31
TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 82, 26, 214, 42
ICON_PNG = {'ic_help': 'MdiLightbulbOutline.png', 'ic_replace': 'MdiBandage.png',
'ic_about': 'MdiInformationOutline.png', 'ic_check': 'MdiClipboardTextSearchOutline.png',
'ic_rollback': 'MdiReplyAllOutline.png', 'ic_qq': 'qq.png'}
def item(y, iconkey, title):
return [
dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R),
dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY, w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE),
dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY, w=ICON_SZ, h=ICON_SZ,
src=os.path.join(ASSETS, ICON_PNG[iconkey])),
dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY, w=TITLE_W, h=TITLE_H,
text=title, size=29, weight=S500, color=WHITE, align='left', vtop=True),
]
def clock(t='10:24'):
return dict(kind='label', x=201, y=4, w=78, h=40, text=t, size=29,
weight=S600, color=GRAY, align='left', vtop=True)
def title(text, x, w=260):
return dict(kind='label', x=x, y=36, w=w, h=42, text=text, size=31,
weight=S500, color=WHITE, align='left', vtop=True)
def back_arrow(x=167):
return dict(kind='image', x=x, y=44, w=18, h=29, src=os.path.join(ASSETS, 'backarrow.png'))
def big_button(y, text):
return [
dict(kind='rect', x=111, y=y, w=258, h=82, bg=PLATE, radius=41),
dict(kind='label', x=182, y=y + 22, w=126, h=40, text=text, size=29,
weight=S500, color=WHITE, align='left', vtop=True),
]
# ------------------------------------------------------------------ 页面
# 所有文字的像素宽度都用真 MiSans 量过(tools/s5e_render_ui.py 同一套字体):
# 31px: 'S5e Pt.2'=125 '快速帮助'/'开始替换'/'正在替换'=124 '关于'=62
# 29px: '确认替换'/'重启手表'=116 '环境预检查'=145 '732681995'=155
# 25px: '检查 health 分区与负载完整性。'=369 '恢复 119 帧动画的原厂样式。'=331
# '预检查通过 | 119 条 | 15.49MB'=349 'Xiaomi Watch S5 eSIM 46mm'=367
# 圆屏在高度 y 处的弦半宽 = √(240² - (y-240)²),所以**越靠下的行越窄** ——
# 型号那行原来放在 y=229(弦半宽只有 171),'Xiaomi Watch S5 eSIM 46mm' 宽 367 直接被切掉。
# 现在把该行拆成两行、整体上移到 y=195 / 228,两行都短于那里的弦。
PAGE_HOME = [clock(), title('S5e Pt.2', 177, 127),
*item(88, 'ic_help', '快速帮助'),
*item(186, 'ic_replace', '开始替换'),
*item(285, 'ic_about', '关于')]
_HELP_HEAD = [clock(), back_arrow(167), title('快速帮助', 188, 124)]
# 说明文字必须**单行**放下(折行后第 2 行会越出圆屏,check_clip 抓过):
# y=182 处圆屏弦半宽 = √(240²-58²) = 232.9,x 从 64 起时最多能到 64+232.9 = 296.9...
# 实际用 380 的框宽是安全的(文字宽 354px < 380,且落在 64..444 内)。
_HELP_D1 = dict(kind='label', x=64, y=182, w=380, h=35,
text='检查 health 分区与负载完整性。', size=24,
weight=S500, color=GRAY, align='left', vtop=True)
_HELP_I2 = item(264, 'ic_rollback', '回滚修改')
_HELP_D2 = dict(kind='label', x=67, y=360, w=348, h=35,
text='恢复 119 帧动画的原厂样式。', size=25,
weight=S500, color=GRAY, align='left', vtop=True)
PAGE_HELP = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [_HELP_D1] + _HELP_I2 + [_HELP_D2]
# 预检查结果:最多 2 行(24px 行高 33,框高 76 >= 2*33)
HELP_DESC_OK = dict(kind='label', x=67, y=182, w=348, h=76,
text='预检查通过 | 119 条 | 15.49MB\nhealth 分区可读。',
size=24, weight=S500, color=OK, align='left', vtop=True)
HELP_DESC_ERR = dict(kind='label', x=67, y=182, w=348, h=76,
text='预检查失败:设备节点读不到,\n或分区头不是 ROMFS。',
size=24, weight=S500, color=ERR, align='left', vtop=True)
PAGE_HELP_OK = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2]
PAGE_HELP_ERR = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2]
# 警告页:每行单独一个框(h=34 ≈ 25px 真行高 35),行距 34。
# 最下面那行放在 y=341:圆屏在 y=375 处正好收成一点,所以底边必须留在 375 以上。
WARN_LABELS = [
dict(kind='label', x=60, y=95, w=360, h=35,
text='替换压力检测动画之前,', size=25, weight=S500, color=GRAY,
align='left', vtop=True),
dict(kind='label', x=60, y=129, w=360, h=35,
text='请先确认自己的系统版本号为 ', size=25, weight=S500, color=GRAY, align='left', vtop=True),
dict(kind='label', x=304, y=129, w=120, h=35, text='3.112.035',
size=25, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=422, y=129, w=40, h=35, text='!',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
# 型号拆成两行:y=195 处弦半宽 195,y=228 处 173 —— 367px 的长行只能拆
dict(kind='label', x=64, y=195, w=360, h=35, text='请确认自己的手表型号为',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
dict(kind='label', x=64, y=229, w=360, h=35, text='Xiaomi Watch S5',
size=25, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=64, y=263, w=360, h=35, text='eSIM 46mm(p62lte)',
size=25, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=60, y=341, w=360, h=35, text='搞机有风险,变砖后果自负。',
size=25, weight=S500, color=GRAY, align='left', vtop=True),
]
PAGE_CONFIRM = [clock(), back_arrow(167), title('开始替换', 188, 124)] + WARN_LABELS + big_button(376, '确认替换')
LOG_MID = ('> 开始替换\n'
'> health 119 条\n'
'> 写入 60/119 成 60 失 0\n'
'> 替换完毕,请重启手表。\n'
'> 若发现问题,请回滚修改。')
PAGE_RUNNING = [clock(), title('正在替换', 178, 124),
dict(kind='label', x=67, y=95, w=348, h=280, text=LOG_MID, size=24,
weight=S500, color=WHITE, align='left', vtop=True),
*big_button(376, '重启手表')]
PAGE_ABOUT = [
clock(), back_arrow(198), title('关于', 220, 62),
dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31),
dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')),
dict(kind='label', x=181, y=200, w=120, h=40, text='S5e Pt.2',
size=29, weight=S500, color=WHITE, align='left', vtop=True),
dict(kind='label', x=222, y=237, w=49, h=28, text='1.0.0',
size=21, weight=S500, color=GRAY, align='left', vtop=True),
*item(300, 'ic_qq', '732681995'),
]
PAGES = {'home': PAGE_HOME, 'help': PAGE_HELP, 'help_ok': PAGE_HELP_OK, 'help_err': PAGE_HELP_ERR,
'confirm': PAGE_CONFIRM, 'running': PAGE_RUNNING, 'about': PAGE_ABOUT}
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""按「角色」切负载:replace(只负责替换)/ restore(只负责还原)。
为什么拆成两张盘
----------------
「负载 + 回滚」合在一张盘 = 两份 15.5 MB ≈ 31 MB,超过 .face 的体积上限。
拆开之后每张盘只装一份负载(≈15.5 MB)。
拆盘后版本校验怎么办
--------------------
每张盘再带一张**指纹表**:每条记录存「另一侧字节的前 64 B」。
* replace 盘:pay = q63 的帧(新字节);fp = **原厂字节**前 64 B
* restore 盘:pay = 原厂字节; fp = **新字节**前 64 B
于是「写入前版本校验」和「写入后回读比对」两道保险都还在,代价只有 119*64 ≈ 7.4 KB。
关键约定(S4e 那版踩过的坑)
----------------------------
Lua 表里存的是**分区键名**('health'),运行期才由 `DEVS[键]` 拼成 `/dev/health`。
若把 '/dev/health' 直接存进表里,拼出来就是 `if=nil` 或 `if=/dev//dev/health`。
产出
----
payload/ NNN_<name>.bin + payload.lua (replace 盘的负载)
payload_restore/ NNN_<name>.bin + payload.lua (restore 盘的负载)
blob_replace/ payload.bin(魔数 + 顺序拼接)+ fp.bin + offsets.json
blob_restore/ 同上
payload.json 机器可读清单
"""
import os, sys, json, hashlib, struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import PROJ, WORK, TARGET_IMAGE
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
assert ROLE in ('replace', 'restore'), ROLE
PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin')
OUT = PROJ
DEV = 'health' # ← Lua 表里的键名(不是 '/dev/health')
FP_LEN = 64
recs_in = json.load(open(os.path.join(WORK, 'anim', 'manifest.json'), encoding='utf-8'))
stock = open(TARGET_IMAGE, 'rb').read()
patched = open(PATCHED, 'rb').read()
assert len(stock) == len(patched), '改后镜像长度变了 —— 等长覆盖的前提被破坏'
assert stock[:8] == b'-rom1fs-'
SRC = patched if ROLE == 'replace' else stock # 要写进设备的字节
OTHER = stock if ROLE == 'replace' else patched # 另一侧字节(做指纹)
FPDIR = os.path.join(OUT, 'payload' if ROLE == 'replace' else 'payload_restore')
BLOBDIR = os.path.join(OUT, 'blob_' + ROLE)
records = []
for r in recs_in['records']:
off, ln = r['offset'], r['length']
records.append(dict(name=r['name'], offset=off, length=ln, data=SRC[off:off + ln],
changed=(stock[off:off + ln] != patched[off:off + ln]),
fp=OTHER[off:off + FP_LEN]))
# replace 盘跳过「本来就没变」的记录(省体积);restore 盘一条都不能少
emitted = [r for r in records if (r['changed'] or ROLE == 'restore')]
dropped = [r for r in records if r not in emitted]
for r in emitted:
assert len(r['data']) == r['length'], r['name']
assert len(r['fp']) == FP_LEN, r['name']
def write_pack(d):
os.makedirs(d, exist_ok=True)
for f in os.listdir(d):
os.remove(os.path.join(d, f))
rows = []
for i, r in enumerate(emitted):
fn = '%03d_%s.bin' % (i, r['name'])
open(os.path.join(d, fn), 'wb').write(r['data'])
assert r['offset'] % 4 == 0, r['name']
rows.append(dict(name=r['name'], file=fn, offset=r['offset'], length=r['length']))
with open(os.path.join(d, 'payload.lua'), 'w', encoding='utf-8', newline='\n') as f:
f.write('-- generated by tools/s5e_payload.py (%s) -- do not hand-edit\n' % ROLE)
f.write('-- dd: -if <this dir>/<file> -of /dev/%s bs=4 seek=offset/4 count=length/4\n' % DEV)
f.write('return {\n')
for r in rows:
f.write(' { name = "%s", file = "%s", offset = %d, length = %d },\n'
% (r['name'], r['file'], r['offset'], r['length']))
f.write('}\n')
return rows
lua_rows = write_pack(FPDIR)
# 魔数必须全局唯一,且**长度不要手写常量**(S4e/S5e 图标包都因为手写成 16 导致偏移差 1)
MAGIC_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c'
MAGIC_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3'
os.makedirs(BLOBDIR, exist_ok=True)
for f in os.listdir(BLOBDIR):
os.remove(os.path.join(BLOBDIR, f))
pay_blob = MAGIC_PAY + b''.join(r['data'] for r in emitted)
fp_blob = MAGIC_FP + b''.join(r['fp'] for r in emitted)
open(os.path.join(BLOBDIR, 'payload.bin'), 'wb').write(pay_blob)
open(os.path.join(BLOBDIR, 'fp.bin'), 'wb').write(fp_blob)
rel, off, fpoff = [], 0, 0
for r in emitted:
# 每条都存独立 sha256:.face 构建时用它校验"确实原样连续存放",
# 而不是拿 .face 跟自己比(那种检查恒真、抓不到 bug)
rel.append(dict(name=r['name'], rel=off, fprel=fpoff, target=r['offset'],
length=r['length'], fplen=len(r['fp']),
pay_sha=hashlib.sha256(pay_blob[len(MAGIC_PAY) + off:
len(MAGIC_PAY) + off + r['length']]).hexdigest(),
fp_sha=hashlib.sha256(fp_blob[len(MAGIC_FP) + fpoff:
len(MAGIC_FP) + fpoff + len(r['fp'])]).hexdigest()))
off += r['length']
fpoff += len(r['fp'])
assert fpoff == len(fp_blob) - len(MAGIC_FP), (fpoff, len(fp_blob))
assert off == len(pay_blob) - len(MAGIC_PAY), (off, len(pay_blob))
json.dump(dict(role=ROLE, device=DEV, magic_len=len(MAGIC_PAY), magic_fp_len=len(MAGIC_FP),
fp_len=FP_LEN, records=rel, pay_bytes=len(pay_blob), fp_bytes=len(fp_blob),
dropped_identical=[r['name'] for r in dropped]),
open(os.path.join(BLOBDIR, 'offsets.json'), 'w', encoding='utf-8'), indent=1)
print('blob_%s/ 单条目: payload.bin %d B + fp.bin %d B(%d 条记录,跳过 %d 条未变)'
% (ROLE, len(pay_blob), len(fp_blob), len(rel), len(dropped)))
json.dump(dict(device='Xiaomi Watch S5 eSIM 46mm (p62lte)', target='/dev/' + DEV, dev_key=DEV,
partition='vela_health.bin', role=ROLE, kind='pressure-measure-anim',
dd='dd if=<dir>/<file> of=/dev/%s bs=4 seek=<offset/4> count=<length/4>' % DEV,
block_size=4, records=lua_rows,
dropped_identical=[r['name'] for r in dropped]),
open(os.path.join(OUT, 'payload.json'), 'w', encoding='utf-8'), indent=1,
ensure_ascii=False)
total = sum(r['length'] for r in lua_rows)
print('role=%s: emitted %d records, %.2f MB; dropped %d'
% (ROLE, len(lua_rows), total / 1048576, len(dropped)))
+196
View File
@@ -0,0 +1,196 @@
"""按 480x480 页面规格渲染预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。
支持 kind: rect / circle / image / label
label 支持 align / vtop / vcenter / lh,以及 LVGL 的 recolor 写法 "#RRGGBB 文字#"
"""
import os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PIL import Image, ImageDraw, ImageFont
from paths import PROJ, INPUTS, FONTS, WORK
ROOT = PROJ
FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf',
'regular': 'MiSans-Regular.ttf'}
W = H = 480
_fc = {}
def _font_from_firmware(name):
"""从 inputs/target/vela_font.bin 里现取 MiSans(固件里就自带这几个 ttf)。"""
p = os.path.join(INPUTS, 'target', 'vela_font.bin')
if not os.path.exists(p):
return None
try:
from romfs import Romfs
r = Romfs(p)
except Exception:
return None
for path, ino in r.entries():
if path == name:
d = os.path.join(WORK, 'fonts')
os.makedirs(d, exist_ok=True)
out = os.path.join(d, name)
open(out, 'wb').write(r.read(ino))
return out
return None
def font(weight, size):
k = (weight, size)
if k in _fc:
return _fc[k]
name = FONT_FILES.get(weight, FONT_FILES['medium'])
p = os.path.join(FONTS, name)
if not os.path.exists(p):
p = _font_from_firmware(name) or p
if not os.path.exists(p):
for alt in (r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\msyhbd.ttc',
r'C:\Windows\Fonts\arial.ttf'):
if os.path.exists(alt):
p = alt
print(' [warn] %s 缺失,预览改用 %s(只影响预览缩略图)'
% (name, os.path.basename(alt)))
break
_fc[k] = ImageFont.truetype(p, size) if os.path.exists(p) else ImageFont.load_default()
return _fc[k]
def _rgb(c):
return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
def split_recolor(text, base):
runs, buf, cur, incol = [], '', base, False
i = 0
while i < len(text):
if text[i] == '#':
if re.match(r'[0-9a-fA-F]{6}', text[i + 1:i + 7]):
runs.append((cur, buf)); buf = ''
cur = text[i + 1:i + 7].lower(); incol = True
i += 7
continue
if incol:
runs.append((cur, buf)); buf = ''
cur = base; incol = False
i += 1
continue
buf += text[i]; i += 1
runs.append((cur, buf))
return runs
def wrap_runs(d, runs, f, maxw):
lines = [[]]
for color, chunk in runs:
for k, seg in enumerate(chunk.split('\n')):
if k:
lines.append([])
if not seg:
continue
cur = ''
for ch in seg:
if d.textlength(cur + ch, font=f) > maxw and cur:
lines[-1].append((color, cur))
lines.append([])
cur = ch
else:
cur += ch
if cur:
lines[-1].append((color, cur))
return lines
OVERFLOW = [] # (文本前 14 字, lh, 行数, 块高, 框高)
def draw_label(img, d, it):
f = font(it.get('weight', 'medium'), it['size'])
runs = split_recolor(it['text'], it.get('color', 'ffffff')) \
if it.get('recolor') else [(it.get('color', 'ffffff'), it['text'])]
asc, desc = f.getmetrics()
natural = asc + desc
lh = max(it.get('lh', 0), natural)
lines = wrap_runs(d, runs, f, it['w'])
total = lh * len(lines)
if total > it['h'] + 0.5:
OVERFLOW.append((it['text'][:14].replace('\n', '/'), round(it.get('lh', 0), 2),
len(lines), round(total, 1), it['h'], round(natural, 2)))
if it.get('vcenter'):
y = it['y'] + (it['h'] - total) / 2
elif it.get('vtop'):
y = it['y']
else:
y = it['y'] + (it['h'] - total) / 2
for ln in lines:
wsum = sum(d.textlength(t, font=f) for _, t in ln)
if it.get('align') == 'center':
x = it['x'] + (it['w'] - wsum) / 2
elif it.get('align') == 'right':
x = it['x'] + it['w'] - wsum
else:
x = it['x']
for color, t in ln:
d.text((x, y), t, font=f, fill=_rgb(color) + (255,))
x += d.textlength(t, font=f)
y += lh
def render_raw(items):
"""画成 RGBA(不遮罩、不铺黑底)—— 切边检查就用这个。"""
img = Image.new('RGBA', (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
for it in items:
k = it.get('kind')
if k == 'rect':
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
r = it.get('radius', 0)
if r:
d.rounded_rectangle(box, radius=r, fill=_rgb(it['bg']) + (255,))
else:
d.rectangle(box, fill=_rgb(it['bg']) + (255,))
elif k == 'circle':
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
d.ellipse(box, fill=_rgb(it['bg']) + (255,))
elif k == 'image':
p = it['src']
if os.path.exists(p):
im = Image.open(p).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS)
img.alpha_composite(im, (it['x'], it['y']))
elif k == 'label':
draw_label(img, d, it)
return img
def render(items, out, mask=True, ring=False):
img = render_raw(items)
if mask:
m = Image.new('L', (W, H), 0)
ImageDraw.Draw(m).ellipse([0, 0, W - 1, H - 1], fill=255)
img.putalpha(m)
base = Image.new('RGB', (W, H), (0, 0, 0))
base.paste(img, (0, 0), img)
if ring:
ImageDraw.Draw(base).ellipse([0, 0, W - 1, H - 1], outline=(80, 80, 90))
os.makedirs(os.path.dirname(out), exist_ok=True)
base.save(out)
return out
if __name__ == '__main__':
from s5e_pages import PAGES
outs = []
for name, items in PAGES.items():
p = os.path.join(ROOT, 'docs', 'ui_%s.png' % name)
render(items, p)
outs.append(p)
print('wrote', p)
cols = 3
rows = (len(outs) + cols - 1) // cols
sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 12) + 12), (28, 28, 32))
for i, p in enumerate(outs):
sheet.paste(Image.open(p), (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12)))
sp = os.path.join(ROOT, 'docs', 'ui_all.png')
sheet.save(sp)
print('wrote', sp, sheet.size)
if OVERFLOW:
print('!! 文本溢出 %d 处:%s' % (len(OVERFLOW), OVERFLOW))
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""UI 素材 PNG -> LVGL v9 .bin(480x480 表盘用)+ 80px 圆形 logo。
格式(真机验证过):12 B 头 magic=0x19|cf|flags|w|h|stride|rsv + BGRA 像素。
编码器复用 iconpack.encode(与图标包同一套)。
"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import iconpack as ip
from PIL import Image, ImageDraw
from paths import PROJ, DOCS, ASSETS
OUT = os.path.join(PROJ, 'ui_images')
os.makedirs(OUT, exist_ok=True)
SPEC = [('back', 'backarrow.png', 18, 29),
('ic_help', 'MdiLightbulbOutline.png', 31, 31),
('ic_replace', 'MdiBandage.png', 31, 31),
('ic_about', 'MdiInformationOutline.png', 31, 31),
('ic_check', 'MdiClipboardTextSearchOutline.png', 31, 31),
('ic_rollback', 'MdiReplyAllOutline.png', 31, 31),
('ic_qq', 'qq.png', 31, 31)]
for name, png, w, h in SPEC:
p = os.path.join(ASSETS, png)
if not os.path.exists(p):
print(' missing', png)
continue
img = Image.open(p).convert('RGBA')
if img.size != (w, h):
img = img.resize((w, h), Image.LANCZOS)
blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0)
open(os.path.join(OUT, name + '.bin'), 'wb').write(blob)
print(' %-12s <- %-34s %dx%d %d bytes' % (name, png, w, h, len(blob)))
L = 80
img = Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA').resize((L, L), Image.LANCZOS)
mask = Image.new('L', (L, L), 0)
ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255)
img.putalpha(mask)
open(os.path.join(OUT, 'logo.bin'), 'wb').write(ip.encode(img, ip.CF_ARGB8888, L, L, 0))
os.makedirs(DOCS, exist_ok=True)
img.save(os.path.join(DOCS, 'logo_preview_480.png'))
print(' %-12s <- cover.png(circular) %dx%d' % ('logo', L, L))
print('->', OUT)
+221
View File
@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""独立校验交付的两张 .face —— 只读交付物 + 固件镜像,不信任任何中间产物。
role=replace -> dist/S5ePAnim.face pay 应等于**改后镜像**的字节,rb 应等于**原厂**前 64 B
role=restore -> dist/S5ePAnimRestore.face pay 应等于**原厂**字节, rb 应等于**改后**前 64 B
查的东西(缺一不可):
① .face 头:magic / b4 / b5 / id / 预览块尺寸 326x326
② 从 .face 里**反解**加载的 Lua 源码:ROLE 正确、占位符已回填、
分区键是 'health'(不是 '/dev/health',那样运行时会拼成 if=nil)
③ pay / rb 表逐条:目标偏移 == 原厂槽位 data_off、长度 == 原槽位长度(等长覆盖的底线)
④ 每条 pay 长度 == 原槽位长度,且能按 LVGL RLE 解出**正好 usize 字节**、解成 464x464
⑤ 每条 pay 字节 == 另一侧镜像对应位置;每条 rb 指纹 == 另一侧前 64 B
⑥ 改后镜像的 inode 表 / superblock / 分区长度与原厂**逐位一致**
⑦ 负载在 .face 里原样连续存放(偏移 == 文件基址 + 魔数长度 + 相对偏移 的前提)
"""
import os, re, sys, json, struct, hashlib
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import (PROJ, INPUTS, WORK, DIST, TARGET_IMAGE, DIRP, FACE_W, FACE_H,
WF_ID, PREVIEW, SCREEN)
from romfs import Romfs
import lvgl_bin as L
ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower()
assert ROLE in ('replace', 'restore'), ROLE
NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore'
FACE = os.path.join(DIST, NAME + '.face')
PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin')
FP_LEN = 64
ok = fail = 0
def ck(cond, msg):
global ok, fail
if cond:
ok += 1
print(' OK ' + msg)
else:
fail += 1
print(' FAIL ' + msg)
print('=' * 78)
print('%s(role=%s) %s' % (NAME, ROLE, FACE))
print('=' * 78)
if not os.path.exists(FACE):
raise SystemExit('交付物不存在:%s' % FACE)
face = open(FACE, 'rb').read()
stock_b = open(TARGET_IMAGE, 'rb').read()
patch_b = open(PATCHED, 'rb').read()
stock = Romfs(TARGET_IMAGE)
patched = Romfs(PATCHED)
# ---------------------------------------------------------------- ① 头
print('\n[1] .face 头')
ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12')
ck(face[4] in (0, 1, 2), 'b4 = %d' % face[4])
ck(face[5] == 0, 'b5 = %d(固件自带表盘 b5 也是 0)' % face[5])
ck(face[40:50] == WF_ID.encode() + b'\x00', 'id = %r(期望 %r)' % (face[40:50], WF_ID))
a32, = struct.unpack('<I', face[32:36])
pal, pw, ph, psz = struct.unpack('<IHHI', face[a32:a32 + 12])
ck((pw, ph) == (PREVIEW, PREVIEW), 'preview %dx%d(Compiler.exe 对 462 设备强制 %dx%d)'
% (pw, ph, PREVIEW, PREVIEW))
ck(a32 + 12 + psz <= len(face) + 1, 'preview 数据 %d B @0x%x(文件尾 0x%x)' % (psz, a32 + 12, len(face)))
print(' 文件 %d B sha256 %s' % (len(face), hashlib.sha256(face).hexdigest()))
# ---------------------------------------------------------------- ② Lua 源码
print('\n[2] 从 .face 里反解加载的 Lua')
_end = face.find(b'return ui\n')
ck(_end > 0, '找到 Lua 结尾 "return ui\\n"')
_start = face.rfind(b'--[[', 0, _end)
lua = face[_start:_end + len(b'return ui\n')]
ck(lua.startswith(b'--[[--') and b'local UIIMG = {' in lua, 'Lua 提取成功(%d B)' % len(lua))
txt = lua.decode('utf-8')
ck(face.count(lua) == 1, 'Lua 原样出现 1 次(偏移 0x%x)' % face.find(lua))
c = re.search(r"local ROLE = '(\w+)'", txt)
ck(c is not None and c.group(1) == ROLE, "Lua 里的 ROLE == '%s'" % ROLE)
ck('__UIIMG__' not in txt and '__PAY__' not in txt and '__RB__' not in txt
and '__ROLE__' not in txt, '占位符已全部回填')
ck(re.search(r'local W, H = %d, %d' % (SCREEN, SCREEN), txt) is not None,
'界面尺寸 W,H = %d,%d(480x480 圆屏)' % (SCREEN, SCREEN))
# ★ S4e 那版踩过的坑:Lua 表里存 '/dev/app',运行时又拼一次 -> if=nil
mdev = re.search(r'local DEVS = \{([^}]*)\}', txt)
ck(mdev is not None and '/dev/health' in mdev.group(1),
'DEVS 里有 health -> /dev/health:%s' % (mdev.group(1).strip() if mdev else None))
# ---------------------------------------------------------------- 偏移(自己找,不信 Lua)
print('\n[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)')
ML_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c'
ML_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3'
offs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'), encoding='utf-8'))
pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read()
fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read()
ck(face.count(ML_PAY) == 1, 'payload 魔数(%d B)在 .face 里唯一' % len(ML_PAY))
ck(face.count(ML_FP) == 1, 'fp 魔数(%d B)在 .face 里唯一' % len(ML_FP))
pay_base = face.find(ML_PAY)
fp_base = face.find(ML_FP)
ck(face.find(pay_blob) == pay_base, 'payload.bin 原样连续存放在 0x%x' % pay_base)
ck(face.find(fp_blob) == fp_base, 'fp.bin 原样连续存放在 0x%x' % fp_base)
recs = offs['records']
ck(len(recs) == 119, '负载记录 %d 条(期望 119)' % len(recs))
# 整个负载文件必须**原样连续**放在 base 处 —— 这是"偏移 == base + 魔数 + rel"成立的前提
ck(face[pay_base:pay_base + len(pay_blob)] == pay_blob,
'payload.bin %d B 在 .face 里逐字节连续' % len(pay_blob))
ck(face[fp_base:fp_base + len(fp_blob)] == fp_blob,
'fp.bin %d B 在 .face 里逐字节连续' % len(fp_blob))
# Lua 表里的偏移必须和独立量出来的一致
pay = [tuple(int(x) for x in r) for r in
re.findall(r'\{(\d+),(\d+),(\d+)\}', re.search(r'pay = \{(.*?)\n \},', txt, re.S).group(1))]
rb = [tuple(int(x) for x in r) for r in
re.findall(r'\{(\d+),(\d+),(\d+)\}', re.search(r'rb = \{(.*?)\n \},', txt, re.S).group(1))]
ck(len(pay) == len(recs) and len(rb) == len(recs),
'Lua 表 pay %d 条 / rb %d 条' % (len(pay), len(rb)))
bad_off = 0
for i, r in enumerate(recs):
if pay[i] != (pay_base + len(ML_PAY) + r['rel'], r['target'], r['length']):
bad_off += 1
if rb[i] != (fp_base + len(ML_FP) + r['fprel'], r['target'], r['fplen']):
bad_off += 1
ck(bad_off == 0, 'Lua 表里的 %d 条偏移 == 文件基址 + 魔数长度 + 相对偏移' % len(recs))
# ---------------------------------------------------------------- 原厂槽位
slots = {}
for p, i in stock.entries():
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0:
continue
slots[i.data_off] = (p, i.size, i)
ck(len(slots) == 119, '原厂 %s 下 %d 个槽位' % (DIRP, len(slots)))
usizes = {struct.unpack('<III', stock.read(slots[o][2])[12:24])[2] for o in slots}
ck(usizes == {1024 + FACE_W * FACE_H + 1},
'原厂 usize 唯一且 == 1024 + %d*%d + 1 = %d' % (FACE_W, FACE_H, 1024 + FACE_W * FACE_H + 1))
# ---------------------------------------------------------------- ④⑤ 逐条
print('\n[4] 逐条负载')
EXPECT_PAY = patch_b if ROLE == 'replace' else stock_b
EXPECT_FP = stock_b if ROLE == 'replace' else patch_b
bad_len = bad_dec = bad_bytes = bad_shape = 0
for i, r in enumerate(recs):
o, t, ln = pay[i]
blob = face[o:o + ln]
if t not in slots or slots[t][1] != ln or ln != r['length']:
bad_len += 1
continue
if EXPECT_PAY[t:t + ln] != blob:
bad_bytes += 1
h = L.parse_header(blob)
if h is None or not (h['flags'] & L.FLAG_COMPRESSED) or (h['w'], h['h']) != (FACE_W, FACE_H):
bad_dec += 1
continue
_m, _cs, _us = struct.unpack('<III', blob[12:24])
if len(L.rle_decompress(blob[24:24 + _cs], 1, None)) != _us or _us not in usizes:
bad_dec += 1
continue
try:
img = L.decode_lvgl_bin(blob)[1]
if img is None or img.size != (FACE_W, FACE_H):
bad_dec += 1
except Exception:
bad_dec += 1
ck(bad_len == 0, '每条负载长度 == 原槽位长度(%d 条)' % len(recs))
ck(bad_bytes == 0, '每条负载字节 == %s镜像对应位置'
% ('改后' if ROLE == 'replace' else '原厂'))
ck(bad_dec == 0, '每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 %dx%d' % (FACE_W, FACE_H))
print('\n[5] 版本校验指纹')
bad_fp = 0
for i, r in enumerate(recs):
o, t, ln = rb[i]
if ln != FP_LEN or t not in slots or face[o:o + ln] != EXPECT_FP[t:t + ln]:
bad_fp += 1
ck(bad_fp == 0, '%d 条指纹都是 64 B 且 == %s镜像前 64 B'
% (len(recs), '原厂' if ROLE == 'replace' else '改后'))
# 指纹必须不是"恒真":至少要有记录真的不同
diff_fp = sum(1 for i, r in enumerate(recs) if stock_b[pay[i][1]:pay[i][1] + FP_LEN] !=
patch_b[pay[i][1]:pay[i][1] + FP_LEN])
ck(diff_fp > 100, '%d/%d 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)'
% (diff_fp, len(recs)))
# ---------------------------------------------------------------- ⑥ 镜像完整性
print('\n[6] 改后镜像的完整性')
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in patched.entries()]
ck(a == b, 'inode 表(路径/偏移/next/spec/size/cksum)%d 条逐位不变' % len(a))
ck(stock.sb_size == patched.sb_size and stock.sb_cksum == patched.sb_cksum,
'superblock 一致(size %d / cksum %08x)' % (patched.sb_size, patched.sb_cksum))
ck(stock.volume == patched.volume == 'health', '卷名 health 不变')
ck(len(stock_b) == len(patch_b), '分区长度不变(%d B)' % len(patch_b))
chg = [i for i in range(len(stock_b)) if stock_b[i] != patch_b[i]]
last_slot_end = max(o + slots[o][1] for o in slots)
ck(chg and chg[0] >= min(slots) and chg[-1] < last_slot_end,
'改动只落在负载槽位区间内(首差 0x%x,末差 0x%x,槽位区间 0x%x..0x%x)'
% (chg[0], chg[-1], min(slots), last_slot_end))
# 每个槽位都必须真的被改过(不是"看起来写了、其实字节没变")
unchanged = [slots[o][0] for o in slots
if stock_b[o:o + slots[o][1]] == patch_b[o:o + slots[o][1]]]
ck(not unchanged, '119 个槽位全部都真的写入了新字节(未变的槽位:%s)' % (unchanged or '无'))
# ---------------------------------------------------------------- ⑦ UI 图
print('\n[7] UI 图片')
uipairs = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt)
ck(len(uipairs) == 8, 'UIIMG 条目 %d 条(期望 8)' % len(uipairs))
badui = 0
for o, l, n in uipairs:
blob = open(os.path.join(PROJ, 'ui_images', n + '.bin'), 'rb').read()
if int(l) != len(blob) or face[int(o):int(o) + int(l)] != blob:
badui += 1
ck(badui == 0, '8 张界面素材都与 ui_images/*.bin 逐字节一致')
# ---------------------------------------------------------------- 体积
print('\n[8] 体积')
ck(len(face) < 20 * 1048576, '交付物 %.2f MB < 20 MB' % (len(face) / 1048576))
print(' 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间')
print('\n' + '=' * 78)
print('%s:%d 项通过,%d 项失败' % (NAME, ok, fail))
if fail:
sys.exit(1)
print('全部通过 —— %s(%d B,sha256 %s)'
% (os.path.basename(FACE), len(face), hashlib.sha256(face).hexdigest()[:16]))
+289
View File
@@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
"""独立总校验 —— 只读两张交付物 + 固件镜像,**不读任何构建中间产物**。
与 tools/s5e_verify.py 的分工
-----------------------------
s5e_verify.py 会读 `blob_<role>/offsets.json` 来核对 Lua 表里的偏移。本脚本刻意
**不读** offsets.json / .work/overlay / .work/build,改为:
* 自己从 `.face` 里反解 Lua(占位符、ROLE、pay/rb/UIIMG 三张表)
* 自己从**固件镜像**里量出 119 个槽位(名字 / 偏移 / 长度)
* 自己证明两张表「自洽 + 连续」:pay[0] 必须紧跟在魔数之后、pay[i+1] == pay[i]+len
* 自己证明**第 k 条负载落在 Measuring{k}.bin 上**(名字对得上,不只是偏移对得上)
* 逐条把负载解成像素:RLE 解出**恰好 usize**,且多出来的那 1 字节正好在像素区之后
(这是"少 1 像素整帧不画"的那个坑)
* 两张交付物**互相印证**(replace 的 pay 前 64 B == restore 的 rb,反之亦然)——
这一条完全不碰 .work,也不碰固件镜像
* 镜像级:inode 表 / superblock / 卷名 / 长度逐位不变;除 119 个槽位外,
**其余每一个文件的每一个字节都逐字节不变**(不是只做区间统计)
用法:python tools/s5e_verify_indep.py
"""
import os, re, sys, hashlib, struct
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import PROJ, DIST, TARGET_IMAGE, DIRP, FACE_W, FACE_H, WF_ID, PREVIEW, SCREEN, WORK
from romfs import Romfs
import lvgl_bin as L
MAGIC_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c'
MAGIC_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3'
FACES = {'replace': 'S5ePAnim.face', 'restore': 'S5ePAnimRestore.face'}
PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin')
UI_NAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback',
'ic_qq', 'logo']
ok = fail = 0
def ck(cond, msg):
global ok, fail
if cond:
ok += 1
print(' OK ' + msg)
else:
fail += 1
print(' FAIL ' + msg)
def parse_face(path):
"""把一张 .face 反解成:头 + Lua 文本 + 三张表 + 魔数位置。"""
d = open(path, 'rb').read()
end = d.find(b'return ui\n')
start = d.rfind(b'--[[', 0, end)
lua = d[start:end + len(b'return ui\n')]
txt = lua.decode('utf-8')
out = dict(data=d, lua=lua, txt=txt, lua_off=start)
m = re.search(r"local ROLE = '(\w+)'", txt)
out['role'] = m.group(1) if m else None
for key, pat in (('pay', r'pay = \{(.*?)\n \},'), ('rb', r'rb = \{(.*?)\n \},')):
blk = re.search(pat, txt, re.S)
out[key] = ([tuple(int(x) for x in t)
for t in re.findall(r'\{(\d+),(\d+),(\d+)\}', blk.group(1))]
if blk else [])
out['ui'] = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt)
out['pay_base'] = d.find(MAGIC_PAY)
out['fp_base'] = d.find(MAGIC_FP)
out['pay_magic_n'] = d.count(MAGIC_PAY)
out['fp_magic_n'] = d.count(MAGIC_FP)
out['lua_n'] = d.count(lua)
return out
def slots_of(stock):
"""固件镜像里的 119 个槽位:名字 -> (偏移, 长度),并给出名字顺序的列表。"""
sl = {}
for p, i in stock.entries():
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0:
continue
sl[os.path.basename(p)] = (i.data_off, i.size)
return sl
def main():
stock_b = open(TARGET_IMAGE, 'rb').read()
patch_b = open(PATCHED, 'rb').read()
stock = Romfs(TARGET_IMAGE)
patched = Romfs(PATCHED)
sl = slots_of(stock)
exp_name = ['Measuring%d.bin' % k for k in range(1, len(sl) + 1)]
print('=' * 78)
print('独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build)')
print(' 交付物 : dist/%s + dist/%s' % (FACES['replace'], FACES['restore']))
print(' 固件 : inputs/target/vela_health.bin %d B(卷名 %s)'
% (len(stock_b), stock.volume))
print(' 改后 : .work/anim/vela_health_anim.bin %d B' % len(patch_b))
print('=' * 78)
ck(len(sl) == 119, '固件里 %s 下 %d 个槽位(期望 119)' % (DIRP, len(sl)))
ck(sorted(sl.keys()) == sorted(exp_name), '槽位名字就是 Measuring1..119.bin')
faces = {}
for role, fn in FACES.items():
f = parse_face(os.path.join(DIST, fn))
faces[role] = f
other = patch_b if role == 'replace' else stock_b
print('\n' + '-' * 78)
print('[%s] %s %d B sha256 %s'
% (role, fn, len(f['data']), hashlib.sha256(f['data']).hexdigest()))
print('-' * 78)
# ---- ① 头 + Lua ----
d = f['data']
ck(d[0:4] == b'\x5a\xa5\x34\x12', 'magic')
ck(d[5] == 0, 'b5 = %d(固件自带表盘是 0)' % d[5])
ck(d[4] == 0, 'b4 = %d' % d[4])
ck(d[40:50] == WF_ID.encode() + b'\x00', 'id = %r' % d[40:50])
a32, = struct.unpack('<I', d[32:36])
pal, pw, ph, psz = struct.unpack('<IHHI', d[a32:a32 + 12])
ck((pw, ph) == (PREVIEW, PREVIEW), 'preview %dx%d' % (pw, ph))
ck(f['lua_n'] == 1, 'Lua 在文件里只出现 1 次(偏移 0x%x)' % f['lua_off'])
ck(f['role'] == role, "Lua 里的 ROLE == '%s'" % role)
for ph_ in ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__'):
ck(ph_ not in f['txt'], '占位符 %s 已回填' % ph_)
ck(re.search(r'local W, H = %d, %d' % (SCREEN, SCREEN), f['txt']) is not None,
'界面尺寸 W,H = %d,%d' % (SCREEN, SCREEN))
m = re.search(r'local DEVS = \{([^}]*)\}', f['txt'])
ck(m is not None and '/dev/health' in m.group(1),
'DEVS 有 health -> /dev/health')
ck(len(f['ui']) == 8, 'UIIMG %d 条(期望 8)' % len(f['ui']))
# ---- ② 表自洽 + 在文件里连续(完全不看 offsets.json)----
pay, rb = f['pay'], f['rb']
ck(len(pay) == 119 and len(rb) == 119, 'pay %d 条 / rb %d 条(期望 119/119)'
% (len(pay), len(rb)))
ck(f['pay_magic_n'] == 1 and f['fp_magic_n'] == 1,
'两个魔数各命中 1 次(payload 0x%x / fp 0x%x)' % (f['pay_base'], f['fp_base']))
cont = 0
for i, (o, _t, ln) in enumerate(pay):
if i == 0:
if o != f['pay_base'] + len(MAGIC_PAY):
cont += 1
elif o != pay[i - 1][0] + pay[i - 1][2]:
cont += 1
for i, (o, _t, ln) in enumerate(rb):
if i == 0:
if o != f['fp_base'] + len(MAGIC_FP):
cont += 1
elif o != rb[i - 1][0] + rb[i - 1][2]:
cont += 1
ck(cont == 0, '两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(%d 处不连续)' % cont)
ck(all(rb[i][2] == 64 for i in range(len(rb))), 'rb 每条都是 64 B')
# ---- ③ 第 k 条落在 Measuring{k}.bin 上 ----
bad_name = bad_len = 0
for i, (o, t, ln) in enumerate(pay):
if t != sl[exp_name[i]][0]:
bad_name += 1
if ln != sl[exp_name[i]][1] or rb[i][1] != t:
bad_len += 1
ck(bad_name == 0, '第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(%d 处不符)' % bad_name)
ck(bad_len == 0, '每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(%d 处不符)'
% bad_len)
ck(all(t % 512 == 0 for _o, t, _l in pay), '119 个目标偏移全部 512 对齐')
odd = sum(1 for _o, _t, l in pay if l % 4 != 0)
print(' (%d/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,'
'否则退化成 bs=1)' % odd)
# ---- ④ 负载字节 == 对应镜像;⑤ 解码 ----
bad_bytes = bad_dec = bad_hdr = 0
big_e = 0
for i, (o, t, ln) in enumerate(pay):
blob = d[o:o + ln]
if blob != other[t:t + ln]:
bad_bytes += 1
if blob == (stock_b if role == 'replace' else patch_b)[t:t + ln]:
big_e += 1
h = L.parse_header(blob)
if (h is None or h['cf'] != 0x0a or not (h['flags'] & L.FLAG_COMPRESSED)
or (h['w'], h['h']) != (FACE_W, FACE_H) or h['stride'] != FACE_W):
bad_hdr += 1
continue
_m, cs, us = struct.unpack('<III', blob[12:24])
try:
raw = L.rle_decompress(blob[24:24 + cs], 1, None)
except Exception:
bad_dec += 1
continue
# ① 解出来必须**恰好** usize(少一字节设备端整帧不画)
# ② usize = 1024 + w*h + 1 -> 像素区之后**正好** 1 字节尾巴(usize_tail)
if (len(raw) != us or us != 1024 + FACE_W * FACE_H + 1
or len(raw) - 1024 - FACE_W * FACE_H != 1):
bad_dec += 1
continue
try:
img = L.decode_lvgl_bin(blob)[1]
if img is None or img.size != (FACE_W, FACE_H):
bad_dec += 1
except Exception:
bad_dec += 1
ck(bad_bytes == 0, '每条负载字节 == %s镜像对应位置(%d 处不符)'
% ('改后' if role == 'replace' else '原厂', bad_bytes))
ck(big_e == 0, '每条负载都真的与"另一侧"不同(%d 条与另一侧相同 —— 应当是 0)' % big_e)
ck(bad_hdr == 0, '每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(%d 条不符)' % bad_hdr)
ck(bad_dec == 0, '每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴'
'(%d 条不符)' % bad_dec)
# ---- ⑥ 指纹有区分度 ----
diff_fp = sum(1 for i in range(len(pay))
if stock_b[pay[i][1]:pay[i][1] + 64] != patch_b[pay[i][1]:pay[i][1] + 64])
ck(diff_fp == 119, '%d/119 条的"原厂前 64 B"与"改后前 64 B"确实不同' % diff_fp)
ck(all(rb[i] == (rb[i][0], sl[exp_name[i]][0], 64) for i in range(len(rb))),
'rb 的 target 也指向对应槽位')
# ---- ⑦ UI 素材 ----
badui = 0
for o, l, n in f['ui']:
blob = open(os.path.join(PROJ, 'ui_images', n + '.bin'), 'rb').read()
if int(l) != len(blob) or d[int(o):int(o) + int(l)] != blob:
badui += 1
ck(badui == 0, '8 张界面素材与 ui_images/*.bin 逐字节一致(%d 处不符)' % badui)
# ================================================================ 两张盘互证
print('\n' + '=' * 78)
print('[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像)')
print('=' * 78)
rp, rr = faces['replace'], faces['restore']
ck(len(rp['data']) == len(rr['data']), '两张盘字节数相同(%d)' % len(rp['data']))
ck(rp['data'][:50] == rr['data'][:50],
'头 50 B(magic / b4 / b5 / id)逐字节相同')
cross = 0
for i in range(119):
if rp['pay'][i][1] != rr['pay'][i][1]:
cross += 1
continue
o1, t, l1 = rp['pay'][i]
o2, _t2, l2 = rr['pay'][i]
if rp['data'][o1:o1 + 64] != rr['data'][rr['rb'][i][0]:rr['rb'][i][0] + 64]:
cross += 1
if rr['data'][o2:o2 + 64] != rp['data'][rp['rb'][i][0]:rp['rb'][i][0] + 64]:
cross += 1
ck(cross == 0, 'replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == '
'replace 的指纹(%d 处不符)' % cross)
same_ui = all(rp['data'][int(o):int(o) + int(l)] == rr['data'][int(o):int(o) + int(l)]
for o, l, _n in rp['ui'])
ck(same_ui, '两张盘的 8 张界面素材逐字节相同(同一套界面)')
ck(rp['pay_base'] == rr['pay_base'] and rp['fp_base'] == rr['fp_base'],
'两张盘的资源基址相同(0x%x / 0x%x)' % (rp['pay_base'], rr['fp_base']))
ndiff = sum(1 for i in range(119)
if rp['data'][rp['pay'][i][0]:rp['pay'][i][0] + rp['pay'][i][2]] !=
rr['data'][rr['pay'][i][0]:rr['pay'][i][0] + rr['pay'][i][2]])
ck(ndiff == 119, '119/119 条负载在两张盘里都不一样(%d)' % ndiff)
# ================================================================ 镜像完整性
print('\n' + '=' * 78)
print('[镜像] 改后镜像 vs 原厂镜像')
print('=' * 78)
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in patched.entries()]
ck(a == b, 'inode 表(路径/偏移/next/spec/size/cksum)%d 条逐位不变' % len(a))
ck((stock.sb_size, stock.sb_cksum, stock.volume) ==
(patched.sb_size, patched.sb_cksum, patched.volume),
'superblock 与卷名不变(size %d / cksum %08x / %r)'
% (patched.sb_size, patched.sb_cksum, patched.volume))
ck(len(stock_b) == len(patch_b), '分区长度不变(%d B)' % len(patch_b))
slot_offs = {o for o, _l in sl.values()}
bad_file = 0
checked = 0
for _p, i in stock.entries():
if i.type == 1 or i.data_off in slot_offs:
continue # 目录项 / 119 个槽位本身:槽位在下面单独查
checked += 1
if stock_b[i.data_off:i.data_off + i.size] != patch_b[i.data_off:i.data_off + i.size]:
bad_file += 1
ck(bad_file == 0, '除 119 个槽位外,其余 %d 个文件的字节**逐字节**不变(%d 处不符)'
% (checked, bad_file))
chg = [i for i in range(len(stock_b)) if stock_b[i] != patch_b[i]]
lo, hi = min(slot_offs), max(o + sl[n][1] for n, (o, _s) in sl.items())
ck(chg and chg[0] >= lo and chg[-1] < hi,
'变化区间 0x%x..0x%x 完全落在槽位区间 0x%x..0x%x 内'
% (chg[0], chg[-1], lo, hi))
ident = [n for n, (o, _s) in sl.items() if stock_b[o:o + sl[n][1]] == patch_b[o:o + sl[n][1]]]
ck(not ident, '119 个槽位全部被改写(未变的:%s)' % (ident or '无'))
print('\n' + '=' * 78)
print('独立总校验:%d 项通过,%d 项失败' % (ok, fail))
print('=' * 78)
return 1 if fail else 0
if __name__ == '__main__':
sys.exit(main())