- Полный 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>
140 строки
5.4 KiB
Python
140 строки
5.4 KiB
Python
#!/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()
|