RT-4D: реверс прошивки, русификация, кастомный UI, флешеры
- Полный RE стока V3.25 (Cortex-M4F) + FM100B: карта памяти, протокол, codeplug, UI-архитектура - Русификация: свой CP1251-шрифт + патч рендера, перевод меню и надписей, ребренд Ru-4D V3.25 - Блюпринт переделки UI + C-тулчейн (clang thumbv7em), доказан инъекцией - Готовые флешеры: WebSerial .html и Windows .exe со вшитой прошивкой - Дамп SPI рации, стоковая прошивка, инструменты сборки Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Этот коммит содержится в:
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build RT-4D app image with narrow single-byte CP1251 Cyrillic support.
|
||||
|
||||
Mechanism (all in the recoverable MCU app region, no SPI writes):
|
||||
1. dispatch patch @0x08008A6A: cmp #0x80 -> #0xFF (bytes 0x80..0xFE take the
|
||||
narrow 7px single-byte path instead of the 14px CJK double-byte path)
|
||||
2. blitter redirect @0x08007FDA: bl SPI_read -> bl CAVE
|
||||
3. CAVE routine (appended): for glyph src addr >= 0x0019C540 (font index >=96,
|
||||
i.e. byte>=0x80) memcpy the 14-byte glyph from our in-flash Cyrillic table;
|
||||
otherwise tail-call the original SPI_read (ASCII unchanged).
|
||||
4. Cyrillic glyph table (appended): 128 cells (byte 0x80..0xFF) x 14 bytes,
|
||||
column-major 7x14 (2 bytes/col LE, bit0=top) — same format as the ASCII font.
|
||||
This test only fills the few glyphs needed to render one CP1251 word.
|
||||
"""
|
||||
import sys, struct
|
||||
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
|
||||
|
||||
BASE = 0x08002800
|
||||
CAVE_ADDR = 0x08028860 # file 0x26060
|
||||
CYR_BASE = 0x08028900 # file 0x26100 (glyphs for byte 0x80..0xFF)
|
||||
SPI_READ = 0x08021828 # original glyph-load routine (thumb entry+1)
|
||||
IMG_END = 0x08029000 # extend image to cover the font table
|
||||
|
||||
# ---- glyph editor: 14 rows x 7 cols, '#'=on ; encode column-major 2B/col LE --
|
||||
# Glyphs are drawn <=6 columns wide (col 6 stays blank) so the fixed 7px advance
|
||||
# always leaves >=1px between letters.
|
||||
def enc(rows):
|
||||
assert len(rows) == 14
|
||||
out = bytearray()
|
||||
for c in range(7):
|
||||
v = 0
|
||||
for r in range(14):
|
||||
if c < len(rows[r]) and rows[r][c] == '#':
|
||||
v |= 1 << r
|
||||
out += struct.pack('<H', v)
|
||||
assert struct.unpack('<H', out[12:14])[0] == 0, "col 6 must stay blank (<=6px wide)"
|
||||
return bytes(out)
|
||||
|
||||
BL = " " # blank row (6 wide)
|
||||
def G(*body, top=3):
|
||||
rows = [BL]*14
|
||||
for i, r in enumerate(body):
|
||||
rows[top+i] = r
|
||||
return enc(rows)
|
||||
|
||||
GLYPHS = { # CP1251 code -> 6px-wide 14-tall bitmap (col 6 blank => 1px gap)
|
||||
0xC7: G(".####.","#....#",".....#","..###.",".....#","#....#",".####."), # З
|
||||
0xEE: G(".####.","#....#","#....#","#....#",".####.", top=5), # о
|
||||
0xED: G("#....#","#....#","######","#....#","#....#", top=5), # н
|
||||
0xFB: G("#....#","#....#","##...#","#.#..#","##...#", top=5), # ы
|
||||
}
|
||||
|
||||
def main():
|
||||
inp = sys.argv[1] if len(sys.argv) > 1 else "rt4d_stock_v3.25_abs_0x08002800.bin"
|
||||
outp = sys.argv[2] if len(sys.argv) > 2 else "rt4d_ru_font_test.bin"
|
||||
stock = open(inp, "rb").read()
|
||||
img = bytearray(b"\xff" * (IMG_END - BASE))
|
||||
img[:len(stock)] = stock
|
||||
|
||||
def w(va, data):
|
||||
img[va-BASE:va-BASE+len(data)] = data
|
||||
|
||||
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
|
||||
|
||||
# --- CAVE ---
|
||||
cave_asm = f"""
|
||||
movw r3, #0xC540
|
||||
movt r3, #0x0019
|
||||
cmp r1, r3
|
||||
blo do_spi
|
||||
subs r1, r1, r3
|
||||
movw r3, #{CYR_BASE & 0xFFFF}
|
||||
movt r3, #{CYR_BASE >> 16}
|
||||
adds r1, r1, r3
|
||||
copy:
|
||||
ldrb r3, [r1]
|
||||
strb r3, [r0]
|
||||
adds r1, r1, #1
|
||||
adds r0, r0, #1
|
||||
subs r2, r2, #1
|
||||
bne copy
|
||||
bx lr
|
||||
do_spi:
|
||||
movw r3, #{SPI_READ & 0xFFFF}
|
||||
movt r3, #{SPI_READ >> 16}
|
||||
orr r3, r3, #1
|
||||
bx r3
|
||||
"""
|
||||
cave, _ = ks.asm(cave_asm, CAVE_ADDR)
|
||||
cave = bytes(cave)
|
||||
assert len(cave) <= (CYR_BASE - CAVE_ADDR), "cave too big"
|
||||
w(CAVE_ADDR, cave)
|
||||
|
||||
# --- blitter redirect: bl CAVE at 0x08007FDA ---
|
||||
bl, _ = ks.asm(f"bl #{CAVE_ADDR}", 0x08007FDA)
|
||||
assert len(bl) == 4
|
||||
orig = bytes(img[0x08007FDA-BASE:0x08007FDA-BASE+4])
|
||||
w(0x08007FDA, bytes(bl))
|
||||
|
||||
# --- dispatch patch: cmp r0,#0x80 -> #0xFF at 0x08008A6A ---
|
||||
dpo = 0x08008A6A - BASE
|
||||
assert img[dpo] == 0x80 and img[dpo+1] == 0x28, f"unexpected {img[dpo]:02x} {img[dpo+1]:02x}"
|
||||
img[dpo] = 0xFF
|
||||
|
||||
# --- Cyrillic glyph table ---
|
||||
for code, g in GLYPHS.items():
|
||||
w(CYR_BASE + (code-0x80)*14, g)
|
||||
|
||||
# --- menu string: "Zone Set 06" -> CP1251 "Зоны"+pad+"06" ---
|
||||
rec = 0x0802543D
|
||||
old = b"Zone Set 06"
|
||||
assert bytes(img[rec-BASE:rec-BASE+16]) == old
|
||||
new = "Зоны".encode("cp1251") + b" "*10 + b"06"
|
||||
assert len(new) == 16, len(new)
|
||||
w(rec, new)
|
||||
|
||||
# sanity: vectors intact
|
||||
sp, rst = struct.unpack_from("<II", img, 0)
|
||||
assert (sp, rst) == struct.unpack_from("<II", stock, 0)
|
||||
|
||||
open(outp, "wb").write(img)
|
||||
print(f"cave {len(cave)}B @0x{CAVE_ADDR:08X}; bl {orig.hex()}->{bytes(bl).hex()}")
|
||||
print(f'"Зоны" cp1251 = {new[:8].hex(" ")}')
|
||||
print(f"image {len(img)} bytes -> {outp}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble the fully-russified RT-4D app image (bold font v2).
|
||||
- narrow single-byte CP1251 render (dispatch patch + blitter code-cave)
|
||||
- bold uppercase Cyrillic font: 13 letters reuse stock ASCII glyphs, 20 drawn
|
||||
- all 485 menu/option records translated to uppercase Russian
|
||||
All patches in the recoverable MCU app region; no SPI writes.
|
||||
(Selection-highlight patch intentionally omitted pending correct RE.)
|
||||
"""
|
||||
import struct, json
|
||||
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
|
||||
import glyphs_ru2 as GR
|
||||
|
||||
BASE = 0x08002800
|
||||
CAVE_ADDR = 0x08028860
|
||||
CYR_BASE = 0x08028900
|
||||
SPI_READ = 0x08021828
|
||||
IMG_END = 0x08029000
|
||||
ROOT = "C:/Users/vikto/Documents/Claude/rt-4d"
|
||||
DUMP = f"{ROOT}/radio-spi-dump.bin"
|
||||
ASCII_FONT = 0x19C000
|
||||
|
||||
def enc_glyph(rows):
|
||||
out = bytearray()
|
||||
for c in range(7):
|
||||
v = sum((1 << r) for r in range(14) if c < len(rows[r]) and rows[r][c] == '#')
|
||||
out += struct.pack('<H', v)
|
||||
return bytes(out)
|
||||
|
||||
def main():
|
||||
stock = open(f"{ROOT}/stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin", "rb").read()
|
||||
dump = open(DUMP, "rb").read()
|
||||
ru = json.load(open(f"{ROOT}/menu_ru.json", encoding="utf-8"))
|
||||
img = bytearray(b"\xff" * (IMG_END - BASE)); img[:len(stock)] = stock
|
||||
def w(va, data): img[va-BASE:va-BASE+len(data)] = data
|
||||
def ascii_glyph(ch):
|
||||
a = ASCII_FONT + (ord(ch)-0x20)*14
|
||||
return dump[a:a+14]
|
||||
|
||||
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
|
||||
cave_asm = f"""
|
||||
movw r3,#0xC540
|
||||
movt r3,#0x0019
|
||||
cmp r1,r3
|
||||
blo do_spi
|
||||
subs r1,r1,r3
|
||||
movw r3,#{CYR_BASE & 0xFFFF}
|
||||
movt r3,#{CYR_BASE >> 16}
|
||||
adds r1,r1,r3
|
||||
copy:
|
||||
ldrb r3,[r1]
|
||||
strb r3,[r0]
|
||||
adds r1,r1,#1
|
||||
adds r0,r0,#1
|
||||
subs r2,r2,#1
|
||||
bne copy
|
||||
bx lr
|
||||
do_spi:
|
||||
movw r3,#{SPI_READ & 0xFFFF}
|
||||
movt r3,#{SPI_READ >> 16}
|
||||
orr r3,r3,#1
|
||||
bx r3
|
||||
"""
|
||||
cave = bytes(ks.asm(cave_asm, CAVE_ADDR)[0]); w(CAVE_ADDR, cave)
|
||||
w(0x08007FDA, bytes(ks.asm(f"bl #{CAVE_ADDR}", 0x08007FDA)[0]))
|
||||
assert img[0x08008A6A-BASE] == 0x80; img[0x08008A6A-BASE] = 0xFF
|
||||
|
||||
# bold Cyrillic font (reuse stock ASCII bytes where identical) --------------
|
||||
for ch in GR.LETTERS:
|
||||
b = ch.encode("cp1251")[0]
|
||||
g = ascii_glyph(GR.REUSE[ch]) if ch in GR.REUSE else enc_glyph(GR.unique_rows(ch))
|
||||
w(CYR_BASE + (b-0x80)*14, g)
|
||||
|
||||
# translate menu records ---------------------------------------------------
|
||||
START = 0x080253ED - BASE; END = 0x0802723D - BASE
|
||||
changed = kept = 0
|
||||
for i in range((END-START)//16):
|
||||
off = START + i*16; r = bytes(img[off:off+16])
|
||||
if r[14:16].isdigit():
|
||||
label = r[:14].rstrip(b' \x00').decode('latin1'); idd = r[14:16]; maxc = 14
|
||||
else:
|
||||
label = r.rstrip(b' \x00').decode('latin1'); idd = b''; maxc = 16
|
||||
if not label or label not in ru: kept += 1; continue
|
||||
rus = ru[label].encode('cp1251'); assert len(rus) <= maxc
|
||||
rec = rus + b' '*(maxc-len(rus)) + idd; assert len(rec) == 16
|
||||
w(BASE+off, rec); changed += 1
|
||||
|
||||
# translate scattered rodata display strings — ALL occurrences (in place,
|
||||
# keep NUL terminator). Lookup covers both menu and rodata translations.
|
||||
rod = json.load(open(f"{ROOT}/rodata_ru.json", encoding="utf-8"))
|
||||
allx = json.load(open(f"{ROOT}/rodata_all.json", encoding="utf-8"))
|
||||
lut = {**ru, **rod}
|
||||
rchg = rskip = 0
|
||||
for it in allx:
|
||||
va, text, maxc = it["va"], it["text"], it["maxc"]
|
||||
rus = lut.get(text)
|
||||
if not rus or rus == text:
|
||||
rskip += 1; continue
|
||||
b = rus.encode("cp1251")
|
||||
if len(b) > maxc or bytes(img[va-BASE:va-BASE+maxc]) != text.encode("latin1"):
|
||||
rskip += 1; continue
|
||||
w(va, b + b" "*(maxc-len(b)))
|
||||
rchg += 1
|
||||
|
||||
# inline fixed-length strings (not NUL-terminated) — exact-length patch
|
||||
INLINE = {
|
||||
0x0800C767: (" Remote Stun: ", " УДАЛ.БЛОК: "),
|
||||
0x0800C77C: ("Remote Stun: ", "УДАЛ.БЛОК: "),
|
||||
0x0800C790: ("Remote Kill: ", "УДАЛ.ОТКЛ: "),
|
||||
0x0800C7B8: ("Wake Up: ", "БУДИЛ: "),
|
||||
0x08028705: ("Freq Mode", "ЧАСТОТА"),
|
||||
0x0802871E: ("DTMF Input: _", "DTMF ВВОД: _"),
|
||||
0x0802872E: ("Dial Number", "НАБОР НОМ."),
|
||||
0x08028767: ("VFO MODE", "VFO РЕЖ"),
|
||||
0x08028815: ("Unknown", "НЕИЗВ."),
|
||||
0x0801FB44: ("Unread SMS : ","НЕПРОЧ.СМС :"),
|
||||
0x08017AB8: ("Sending", "ОТПРАВ."),
|
||||
}
|
||||
ichg = 0
|
||||
for va, (en, rus) in INLINE.items():
|
||||
b = rus.encode("cp1251"); assert len(b) <= len(en), (en, rus)
|
||||
if bytes(img[va-BASE:va-BASE+len(en)]) != en.encode("latin1"):
|
||||
continue
|
||||
w(va, b + b" "*(len(en)-len(b)))
|
||||
ichg += 1
|
||||
|
||||
# version rebrand: "RT-4D V3.25" -> "Ru-4D V3.25"
|
||||
vva = 0x0800BA88
|
||||
if bytes(img[vva-BASE:vva-BASE+16]) == b"VER :RT-4D V3.25":
|
||||
w(vva, b"VER :Ru-4D V3.25")
|
||||
|
||||
assert struct.unpack_from("<II", img, 0) == struct.unpack_from("<II", stock, 0)
|
||||
out = f"{ROOT}/stock-fw/rt4d_ru_full.bin"
|
||||
open(out, "wb").write(img)
|
||||
print(f"cave {len(cave)}B | font {len(GR.LETTERS)} glyphs | menu {changed} | "
|
||||
f"rodata occ {rchg} (skip {rskip}) | inline {ichg}")
|
||||
print(f"image {len(img)}B -> {out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inject the compiled custom UI blob (ui/my_ui_code.bin, my_draw_router @0x08029000)
|
||||
over the russified image and redirect the draw-dispatch bl @0x080207E6 to it."""
|
||||
import struct
|
||||
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
|
||||
|
||||
BASE = 0x08002800
|
||||
UI_BASE = 0x08029000 # free flash, after russification cave+font
|
||||
DRAW_BL = 0x080207E6 # bl 0x0801E1BC in ui_tick_normal
|
||||
ROOT = "C:/Users/vikto/Documents/Claude/rt-4d"
|
||||
|
||||
def main():
|
||||
img = bytearray(open(f"{ROOT}/stock-fw/rt4d_ru_full.bin", "rb").read())
|
||||
blob = open(f"{ROOT}/ui/my_ui_code.bin", "rb").read() # my_draw_router first
|
||||
end = (UI_BASE - BASE) + len(blob)
|
||||
if len(img) < end:
|
||||
img += b"\xff" * (end - len(img))
|
||||
img[UI_BASE-BASE:UI_BASE-BASE+len(blob)] = blob
|
||||
|
||||
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
|
||||
old = bytes(img[DRAW_BL-BASE:DRAW_BL-BASE+4])
|
||||
new = bytes(ks.asm(f"bl #{UI_BASE}", DRAW_BL)[0]); assert len(new) == 4
|
||||
img[DRAW_BL-BASE:DRAW_BL-BASE+4] = new
|
||||
|
||||
out = f"{ROOT}/stock-fw/rt4d_ui_home.bin"
|
||||
open(out, "wb").write(img)
|
||||
print(f"blob {len(blob)}B @0x{UI_BASE:08X} | draw bl @0x{DRAW_BL:08X}: {old.hex()} -> {new.hex()}")
|
||||
print(f"image {len(img)}B -> {out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase-0 UI hook PoC, built ON TOP of the russified image.
|
||||
|
||||
Injects a custom draw-router at 0x08029000 (free flash, after the russification
|
||||
cave@0x08028860 + font@0x08028900). Redirects ONLY the draw dispatch bl at
|
||||
0x080207E6 to the router, which:
|
||||
- tail-calls the stock draw dispatcher 0x0801E1BC (screen renders normally),
|
||||
- then, if g_screen==0 (home/standby), paints a custom "МОД" marker via the
|
||||
stock draw_string SDK (also exercising our CP1251 Cyrillic cave).
|
||||
Key dispatch is left 100% stock. One 4-byte code patch; fully recoverable.
|
||||
"""
|
||||
import struct
|
||||
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
|
||||
|
||||
BASE = 0x08002800
|
||||
ROUTER = 0x08029000
|
||||
MARKER = 0x08029040
|
||||
DRAW_BL = 0x080207E6 # bl 0x0801E1BC in ui_tick_normal
|
||||
STOCK_DRAW= 0x0801E1BC
|
||||
DRAW_STR = 0x08008A50
|
||||
G_SCREEN = 0x200008B3
|
||||
ROOT = "C:/Users/vikto/Documents/Claude/rt-4d"
|
||||
|
||||
def main():
|
||||
img = bytearray(open(f"{ROOT}/stock-fw/rt4d_ru_full.bin", "rb").read())
|
||||
if len(img) < (MARKER+8-BASE):
|
||||
img += b"\xff" * ((MARKER+8-BASE) - len(img))
|
||||
def w(va, b): img[va-BASE:va-BASE+len(b)] = b
|
||||
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
|
||||
|
||||
router = f"""
|
||||
push {{r4, lr}}
|
||||
movw r4, #{STOCK_DRAW+1 & 0xFFFF}
|
||||
movt r4, #{(STOCK_DRAW+1) >> 16}
|
||||
blx r4 // stock draw dispatch (renders current screen)
|
||||
movw r4, #{G_SCREEN & 0xFFFF}
|
||||
movt r4, #{G_SCREEN >> 16}
|
||||
ldrb r4, [r4]
|
||||
cmp r4, #0
|
||||
bne done // only decorate the home screen
|
||||
sub sp, #8
|
||||
movs r0, #0
|
||||
str r0, [sp] // mode = 0 (arg5 -> [sp+0x28] inside draw_string)
|
||||
movs r0, #0 // page (top)
|
||||
movs r1, #104 // x
|
||||
movw r2, #{MARKER & 0xFFFF}
|
||||
movt r2, #{MARKER >> 16}
|
||||
movs r3, #3 // len
|
||||
movw r4, #{DRAW_STR+1 & 0xFFFF}
|
||||
movt r4, #{(DRAW_STR+1) >> 16}
|
||||
blx r4 // draw_string(0,104,marker,3,0)
|
||||
add sp, #8
|
||||
done:
|
||||
pop {{r4, pc}}
|
||||
"""
|
||||
code = bytes(ks.asm(router, ROUTER)[0])
|
||||
assert len(code) <= (MARKER-ROUTER), f"router too big: {len(code)}"
|
||||
w(ROUTER, code)
|
||||
w(MARKER, "МОД".encode("cp1251") + b"\x00")
|
||||
|
||||
# redirect the draw dispatch bl -> router
|
||||
old = bytes(img[DRAW_BL-BASE:DRAW_BL-BASE+4])
|
||||
new = bytes(ks.asm(f"bl #{ROUTER}", DRAW_BL)[0]); assert len(new) == 4
|
||||
w(DRAW_BL, new)
|
||||
|
||||
out = f"{ROOT}/stock-fw/rt4d_ui_poc.bin"
|
||||
open(out, "wb").write(img)
|
||||
print(f"router {len(code)}B @0x{ROUTER:08X} | marker МОД @0x{MARKER:08X}")
|
||||
print(f"draw bl @0x{DRAW_BL:08X}: {old.hex()} -> {new.hex()}")
|
||||
print(f"image {len(img)}B -> {out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Robust RT-4D application flasher (bootloader / 0x39 protocol).
|
||||
|
||||
Replicates the official DualTachyon flasher-cli handshake:
|
||||
1. 0xFF probe — spam 0xFF until the bootloader echoes 0xFF
|
||||
2. erase — two-part official-updater sequence (checksum seed 0x48)
|
||||
[0x39,0x33,0x05,0x10] then [0x39,0x33,0x05,0x55]
|
||||
3. write — [0x57, off>>8, off] + 1024B, checksum seed 0x48, ACK 0x06
|
||||
|
||||
Only the APPLICATION region is erased/written; the bootloader at
|
||||
0x08000000..0x08002800 is never touched, so a failed flash is always
|
||||
recoverable by reflashing the stock app image.
|
||||
|
||||
Usage: python flash_rt4d.py COM7 <firmware.bin>
|
||||
"""
|
||||
import sys, time, serial
|
||||
|
||||
SEED = 0x48
|
||||
ACK = 0x06
|
||||
|
||||
|
||||
def cksum(payload):
|
||||
s = SEED
|
||||
for b in payload:
|
||||
s = (s + b) & 0xFF
|
||||
return bytes(payload) + bytes([s])
|
||||
|
||||
|
||||
def handshake(p, timeout=5.0):
|
||||
p.reset_input_buffer()
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
b = p.read(1)
|
||||
if not b:
|
||||
p.write(bytes([0xFF]))
|
||||
elif b == b"\xff":
|
||||
return True
|
||||
# ignore any other stray byte and keep probing
|
||||
return False
|
||||
|
||||
|
||||
def erase(p):
|
||||
for trig in (0x10, 0x55):
|
||||
p.reset_input_buffer()
|
||||
p.write(cksum([0x39, 0x33, 0x05, trig]))
|
||||
# wait up to ~4s for ACK
|
||||
ok = False
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 4.0:
|
||||
b = p.read(1)
|
||||
if b == bytes([ACK]):
|
||||
ok = True
|
||||
break
|
||||
if b: # unexpected byte
|
||||
pass
|
||||
if not ok:
|
||||
raise SystemExit(f"[E] erase phase 0x{trig:02X}: no ACK")
|
||||
print(f"[i] erase 0x{trig:02X}: ACK")
|
||||
|
||||
|
||||
def write_fw(p, fw):
|
||||
# pad up to a 1024 multiple with 0xFF (erased-flash value)
|
||||
pad = (-len(fw)) % 1024
|
||||
data = fw + b"\xff" * pad
|
||||
total = len(data)
|
||||
for off in range(0, total, 1024):
|
||||
chunk = data[off:off + 1024]
|
||||
payload = bytes([0x57, (off >> 8) & 0xFF, off & 0xFF]) + chunk
|
||||
p.reset_input_buffer()
|
||||
p.write(cksum(payload))
|
||||
# ACK
|
||||
ok = False
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 2.0:
|
||||
b = p.read(1)
|
||||
if b == bytes([ACK]):
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
raise SystemExit(f"\n[E] write at 0x{off:05X}: no ACK "
|
||||
f"(wrote {off}/{total})")
|
||||
pct = (off + len(chunk)) * 100 // total
|
||||
print(f"\r[i] writing 0x{off:05X}/{total:#07x} ({pct:3d}%)", end="", flush=True)
|
||||
print("\n[i] write complete")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit(__doc__)
|
||||
port, path = sys.argv[1], sys.argv[2]
|
||||
fw = open(path, "rb").read()
|
||||
print(f"[i] firmware {path}: {len(fw)} bytes (0x{len(fw):X})")
|
||||
p = serial.Serial(port, 115200, timeout=0.3, write_timeout=3)
|
||||
try:
|
||||
print("[i] handshake (0xFF probe)...")
|
||||
if not handshake(p):
|
||||
raise SystemExit("[E] no bootloader (radio not in flash mode). "
|
||||
"Power off, hold * (or the flash-mode key) and power on.")
|
||||
print("[i] bootloader ready")
|
||||
print("[i] erasing application region...")
|
||||
erase(p)
|
||||
print("[i] flashing...")
|
||||
write_fw(p, fw)
|
||||
print("[i] DONE. Power-cycle the radio normally.")
|
||||
finally:
|
||||
p.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Uppercase Russian bitmap font for the RT-4D, 6px wide (col 6 blank => 1px
|
||||
gap with the fixed 7px advance), body rows 3..10 (descenders to 11).
|
||||
Format encoded elsewhere: column-major 7 cols x 14 rows, 2 bytes/col LE, bit0=top.
|
||||
Each glyph here is a list of up to 9 six-char rows placed starting at row `TOP`."""
|
||||
|
||||
TOP = 3
|
||||
|
||||
# 6-wide bitmaps (cols 0..5). '#'=pixel. Bottom-aligned on baseline row 10.
|
||||
UPPER = {
|
||||
"А": ["_####_","#____#","#____#","#____#","######","#____#","#____#","#____#"],
|
||||
"Б": ["######","#_____","#_____","#####_","#____#","#____#","#____#","#####_"],
|
||||
"В": ["#####_","#____#","#____#","#####_","#____#","#____#","#____#","#####_"],
|
||||
"Г": ["######","#_____","#_____","#_____","#_____","#_____","#_____","#_____"],
|
||||
"Д": ["_####_","_#__#_","_#__#_","_#__#_","_#__#_","######","#____#","#____#"],
|
||||
"Е": ["######","#_____","#_____","#####_","#_____","#_____","#_____","######"],
|
||||
"Ё": ["#_##_#","______","######","#_____","#####_","#_____","#_____","######"],
|
||||
"Ж": ["#_#_#_","#_#_#_","_###__","__#___","_###__","#_#_#_","#_#_#_","#_#_#_"],
|
||||
"З": ["_###__","#___#_","____#_","__##__","____#_","#___#_","_###__","______"],
|
||||
"И": ["#____#","#___##","#__#_#","#_#__#","##___#","#____#","#____#","#____#"],
|
||||
"Й": ["_####_","#____#","#___##","#__#_#","#_#__#","##___#","#____#","#____#"],
|
||||
"К": ["#___#_","#__#__","#_#___","##____","#_#___","#__#__","#___#_","#____#"],
|
||||
"Л": ["_####_","_#__#_","_#__#_","_#__#_","_#__#_","#___#_","#___#_","#___#_"],
|
||||
"М": ["#____#","##__##","#_##_#","#_##_#","#____#","#____#","#____#","#____#"],
|
||||
"Н": ["#____#","#____#","#____#","######","#____#","#____#","#____#","#____#"],
|
||||
"О": ["_####_","#____#","#____#","#____#","#____#","#____#","#____#","_####_"],
|
||||
"П": ["######","#____#","#____#","#____#","#____#","#____#","#____#","#____#"],
|
||||
"Р": ["#####_","#____#","#____#","#####_","#_____","#_____","#_____","#_____"],
|
||||
"С": ["_####_","#____#","#_____","#_____","#_____","#_____","#____#","_####_"],
|
||||
"Т": ["######","__#___","__#___","__#___","__#___","__#___","__#___","__#___"],
|
||||
"У": ["#____#","#____#","#____#","_####_","___#__","___#__","__#___","_##___"],
|
||||
"Ф": ["__#___","_####_","#_#__#","#_#__#","#_#__#","_####_","__#___","__#___"],
|
||||
"Х": ["#____#","#____#","_#__#_","__##__","__##__","_#__#_","#____#","#____#"],
|
||||
"Ц": ["#___#_","#___#_","#___#_","#___#_","#___#_","#___#_","######","____##"],
|
||||
"Ч": ["#____#","#____#","#____#","_#####","_____#","_____#","_____#","_____#"],
|
||||
"Ш": ["#_#__#","#_#__#","#_#__#","#_#__#","#_#__#","#_#__#","#_#__#","######"],
|
||||
"Щ": ["#_#__#","#_#__#","#_#__#","#_#__#","#_#__#","#_#__#","######","_____#"],
|
||||
"Ъ": ["##____","_#____","_#____","_####_","_#___#","_#___#","_#___#","_####_"],
|
||||
"Ы": ["#____#","#____#","#____#","##___#","#_#__#","#_#__#","#_#__#","##___#"],
|
||||
"Ь": ["#_____","#_____","#_____","#####_","#____#","#____#","#____#","#####_"],
|
||||
"Э": ["_###__","#___#_","____#_","__###_","____#_","____#_","#___#_","_###__"],
|
||||
"Ю": ["#__##_","#_#__#","#_#__#","###__#","#_#__#","#_#__#","#_#__#","#__##_"],
|
||||
"Я": ["_#####","#____#","#____#","_#####","__#__#","_#___#","#____#","#____#"],
|
||||
}
|
||||
|
||||
def rows_for(ch):
|
||||
body = UPPER[ch]
|
||||
grid = ["______"] * 14
|
||||
for i, r in enumerate(body):
|
||||
grid[TOP + i] = r.replace("_", ".")
|
||||
return grid
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bold (2px) uppercase Russian font, 6px wide (cols 1..6, col0 = 1px left
|
||||
bearing), body rows 3..11 (all glyphs 9 rows tall to match the stock ASCII caps).
|
||||
12 letters reuse the stock ASCII/digit glyph bytes; the rest are hand-drawn.
|
||||
"""
|
||||
|
||||
# Cyrillic upper -> stock ASCII char whose glyph bitmap is identical
|
||||
REUSE = {"А":"A","В":"B","Е":"E","З":"3","К":"K","М":"M","Н":"H",
|
||||
"О":"O","Р":"P","С":"C","Т":"T","Х":"X"}
|
||||
|
||||
# hand-drawn, exactly 9 rows -> placed at rows 3..11 (top-of-cap = 3, baseline = 11)
|
||||
_U = {
|
||||
"Б":[".######",".##....",".##....",".#####.",".##..##",".##..##",".##..##",".##..##",".#####."],
|
||||
"Г":[".######",".##....",".##....",".##....",".##....",".##....",".##....",".##....",".##...."],
|
||||
"Д":["..#####","..##..#","..##..#","..##..#","..##..#","..##..#","..##..#",".######",".##..##"],
|
||||
"Ж":[".#.#.#.",".#.#.#.","..###..","...#...","...#...","..###..",".#.#.#.",".#.#.#.",".#.#.#."],
|
||||
"И":[".##..##",".##..##",".##.###",".#####.",".#####.",".###.##",".##..##",".##..##",".##..##"],
|
||||
"Й":["..####.","...##..",".##..##",".##.###",".#####.",".###.##",".##..##",".##..##",".##..##"],
|
||||
"Л":["..#####","..##..#","..##..#","..##..#","..##..#","..##..#",".##...#",".##...#",".##...#"],
|
||||
"П":[".######",".##..##",".##..##",".##..##",".##..##",".##..##",".##..##",".##..##",".##..##"],
|
||||
"У":[".##.##.",".##.##.","..###..","...##..","...##..","...##..","..##...",".##....",".#....."],
|
||||
"Ф":["...##..",".######",".#.##.#",".#.##.#",".#.##.#",".######","...##..","...##..","...##.."],
|
||||
"Ц":[".##.##.",".##.##.",".##.##.",".##.##.",".##.##.",".##.##.",".##.##.",".#####.","....##."],
|
||||
"Ч":[".##..##",".##..##",".##..##",".######",".....##",".....##",".....##",".....##",".....##"],
|
||||
"Ш":[".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".######"],
|
||||
"Щ":[".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".#.##.#",".######","......#"],
|
||||
"Ъ":[".###...","..##...","..##...","..##...","..####.","..##.##","..##.##","..##.##","..####."],
|
||||
"Ы":[".##...#",".##...#",".##...#",".##...#",".####.#",".##.#.#",".##.#.#",".##.#.#",".####.#"],
|
||||
"Ь":[".##....",".##....",".##....",".##....",".#####.",".##..##",".##..##",".##..##",".#####."],
|
||||
"Э":[".#####.",".##..##",".....##","...####",".....##",".....##",".....##",".##..##",".#####."],
|
||||
"Ю":[".#..##.",".#.#..#",".#.#..#",".####.#",".#.#..#",".#.#..#",".#.#..#",".#.#..#",".#..##."],
|
||||
"Я":[".######",".##..##",".##..##",".######",".....##","....#.#","...#..#","..#...#",".#....#"],
|
||||
"Ё":[".#.#...",".......",".######",".##....",".#####.",".##....",".##....",".##....",".######"],
|
||||
}
|
||||
|
||||
def unique_rows(ch):
|
||||
body = _U[ch] # exactly 9 rows -> rows 3..11
|
||||
grid = ["......."] * 14
|
||||
for i, r in enumerate(body):
|
||||
grid[3 + i] = r
|
||||
return grid
|
||||
|
||||
LETTERS = list(REUSE.keys()) + list(_U.keys())
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RT-4D firmware binary patcher.
|
||||
|
||||
Applies in-place, equal-size byte patches to the stock V3.25 app image
|
||||
(base 0x08002800). All patches are verified against expected original
|
||||
bytes before writing, the file size is asserted unchanged, and the
|
||||
Cortex-M vector table (SP + reset) is re-checked afterwards so a bad
|
||||
patch can never silently corrupt the load image.
|
||||
|
||||
Usage:
|
||||
python patch_fw.py <in.bin> <out.bin> <patchset>
|
||||
patchset = name of a list defined in PATCHSETS below (e.g. 'rebrand')
|
||||
"""
|
||||
import sys, struct
|
||||
|
||||
BASE = 0x08002800
|
||||
|
||||
# Each patch: (virtual_address, expected_original_bytes, new_bytes)
|
||||
# new_bytes MUST equal len(expected) — size never changes.
|
||||
PATCHSETS = {
|
||||
# Warm-up: flip the version-string NUL terminator to 'c' so the
|
||||
# radio shows "RT-4D V3.25c" — a 1-byte, fully reversible marker
|
||||
# that proves the patch->flash pipeline end to end.
|
||||
"rebrand": [
|
||||
(0x0800BA98, b"\x00", b"c"),
|
||||
],
|
||||
# Visible warm-up: the on-screen version field is fixed 16-wide, so a
|
||||
# trailing char is clipped. Change the last VISIBLE glyph '5' -> 'C'
|
||||
# so the radio shows "RT-4D V3.2C" — same length, in-field, unmistakable.
|
||||
"rebrand2": [
|
||||
(0x0800BA97, b"5", b"C"),
|
||||
],
|
||||
# Russification PoC: menu item "Zone Set 06" -> "Зоны" (GBK A7-row
|
||||
# Cyrillic, 8 bytes) + 6 spaces + preserved 2-byte id "06". Stays exactly
|
||||
# 16 bytes. Resolves the A7 byte->glyph mapping on-target.
|
||||
"ru_poc": [
|
||||
(0x0802543D, b"Zone Set 06",
|
||||
bytes.fromhex("a7a9a7e0a7dfa7ed") + b" " + b"06"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def apply_patches(data: bytearray, patches):
|
||||
report = []
|
||||
for va, orig, new in patches:
|
||||
assert len(orig) == len(new), f"length mismatch at 0x{va:08X}"
|
||||
off = va - BASE
|
||||
assert 0 <= off < len(data), f"0x{va:08X} out of range"
|
||||
cur = bytes(data[off:off + len(orig)])
|
||||
if cur != orig:
|
||||
raise SystemExit(
|
||||
f"[E] verify failed at 0x{va:08X}: have {cur.hex(' ')}, "
|
||||
f"expected {orig.hex(' ')} (wrong input or already patched)")
|
||||
data[off:off + len(new)] = new
|
||||
report.append((va, orig, new))
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit(__doc__)
|
||||
inp, outp, setname = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
patches = PATCHSETS[setname]
|
||||
data = bytearray(open(inp, "rb").read())
|
||||
n0 = len(data)
|
||||
sp0, rst0 = struct.unpack_from("<II", data, 0)
|
||||
rep = apply_patches(data, patches)
|
||||
# invariants
|
||||
assert len(data) == n0, "size changed!"
|
||||
sp1, rst1 = struct.unpack_from("<II", data, 0)
|
||||
assert (sp1, rst1) == (sp0, rst0), "vector table (SP/reset) changed!"
|
||||
open(outp, "wb").write(data)
|
||||
print(f"[i] patchset '{setname}': {len(rep)} patch(es) applied")
|
||||
for va, orig, new in rep:
|
||||
print(f" 0x{va:08X}: {orig.hex(' ')} -> {new.hex(' ')}")
|
||||
print(f"[i] size unchanged: {n0} bytes; vectors intact "
|
||||
f"(SP=0x{sp1:08X} reset=0x{rst1:08X})")
|
||||
print(f"[i] wrote {outp}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Ссылка в новой задаче
Block a user