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>
Этот коммит содержится в:
2026-07-08 15:47:22 +09:00
co-authored by Claude Opus 4.8
Коммит ae36c3b729
72 изменённых файлов: 24124 добавлений и 0 удалений
+139
Просмотреть файл
@@ -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()