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 удалений
Двоичные данные
Двоичный файл не отображается.
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
+164
Просмотреть файл
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Generate a self-contained tkinter GUI flasher (firmware embedded) and build
it into a single Windows .exe with PyInstaller."""
import base64, os, subprocess, sys
ROOT = "C:/Users/vikto/Documents/Claude/rt-4d"
FW = f"{ROOT}/stock-fw/rt4d_ru_full.bin"
DIST = f"{ROOT}/dist"
fw = open(FW, "rb").read()
b64 = base64.b64encode(fw).decode()
info = f"Ru-4D V3.25 (русифицированная) - {len(fw)} байт"
GUI = r'''# -*- coding: utf-8 -*-
import base64, time, threading, queue
import tkinter as tk
from tkinter import ttk
import serial
from serial.tools import list_ports
FW = base64.b64decode("__B64__")
INFO = "__INFO__"
SEED = 0x48
def cksum(b):
return bytes([(SEED + sum(b)) & 0xFF])
def find_ports():
return list(list_ports.comports())
def auto_port(ports):
for p in ports: # CH343 = VID 0x1A86
if (p.vid or 0) == 0x1A86:
return p.device
return ports[0].device if ports else None
def do_flash(port_name, log, prog):
p = serial.Serial(port_name, 115200, timeout=0.3, write_timeout=3)
try:
log("Порт " + port_name + " открыт. Синхронизация...")
p.reset_input_buffer()
ready = False; t0 = time.time()
while time.time() - t0 < 7:
b = p.read(1)
if not b: p.write(b"\xff")
elif b == b"\xff": ready = True; break
if not ready:
raise RuntimeError("Рация не отвечает. Выключите и включите её\nс зажатой клавишей * , затем повторите.")
log("Бутлоадер найден. Стирание...")
for trig in (0x10, 0x55):
pl = bytes([0x39, 0x33, 0x05, trig]); pl += cksum(pl)
p.reset_input_buffer(); p.write(pl)
ok = False; t = time.time()
while time.time() - t < 4:
if p.read(1) == b"\x06": ok = True; break
if not ok: raise RuntimeError("Ошибка стирания.")
log("Запись прошивки...")
pad = (-len(FW)) % 1024
data = FW + b"\xff" * pad; total = len(data)
for off in range(0, total, 1024):
chunk = data[off:off + 1024]
pl = bytes([0x57, (off >> 8) & 0xFF, off & 0xFF]) + chunk; pl += cksum(pl)
p.reset_input_buffer(); p.write(pl)
ok = False; t = time.time()
while time.time() - t < 3:
if p.read(1) == b"\x06": ok = True; break
if not ok: raise RuntimeError("Ошибка записи на 0x%X." % off)
prog((off + 1024) / total)
prog(1.0)
log("[OK] Готово! Выключите и включите рацию обычно.")
finally:
p.close()
class App:
def __init__(self, root):
self.root = root; self.q = queue.Queue(); self.busy = False
root.title("Ru-4D - Прошивка рации"); root.geometry("460x520"); root.resizable(False, False)
root.configure(bg="#131a24")
F = dict(bg="#131a24", fg="#dfe7ef")
tk.Label(root, text="Ru-4D - Прошивка рации", font=("Segoe UI", 15, "bold"), **F).pack(pady=(16, 0))
tk.Label(root, text=INFO, font=("Segoe UI", 9), bg="#131a24", fg="#7f8ea3").pack()
steps = ("1. Выключите рацию.\n"
"2. Включите её, удерживая клавишу * (режим прошивки).\n"
"3. Подключите рацию к ПК кабелем USB-C.\n"
"4. Выберите COM-порт и нажмите Прошить .\n"
"5. Не отключайте кабель до конца записи.")
tk.Label(root, text=steps, justify="left", font=("Segoe UI", 10), bg="#131a24", fg="#c3cdd9").pack(padx=18, pady=12, anchor="w")
row = tk.Frame(root, bg="#131a24"); row.pack(fill="x", padx=18)
tk.Label(row, text="Порт:", **F).pack(side="left")
self.port_var = tk.StringVar()
self.port_cb = ttk.Combobox(row, textvariable=self.port_var, state="readonly", width=28)
self.port_cb.pack(side="left", padx=6)
tk.Button(row, text="", command=self.refresh, width=3).pack(side="left")
self.btn = tk.Button(root, text="Прошить", font=("Segoe UI", 13, "bold"),
bg="#22bb77", fg="#04120b", activebackground="#2ad", relief="flat",
command=self.start)
self.btn.pack(fill="x", padx=18, pady=(14, 6))
self.pb = ttk.Progressbar(root, maximum=1000); self.pb.pack(fill="x", padx=18, pady=4)
self.log = tk.Text(root, height=9, font=("Consolas", 9), bg="#0c1219", fg="#9fb0c2",
relief="flat", wrap="word"); self.log.pack(fill="both", expand=True, padx=18, pady=(6, 14))
self.refresh(); self.poll()
def refresh(self):
ports = find_ports()
self.port_cb["values"] = [f"{p.device} - {p.description}" for p in ports]
ap = auto_port(ports)
if ap:
for i, p in enumerate(ports):
if p.device == ap: self.port_cb.current(i); break
def logline(self, m, tag=None):
self.log.insert("end", m + "\n"); self.log.see("end")
def prog(self, f): self.pb["value"] = int(f * 1000)
def start(self):
if self.busy: return
sel = self.port_var.get()
if not sel:
self.logline("Выберите COM-порт (подключите рацию в режиме прошивки)."); return
port = sel.split(" - ")[0]
self.busy = True; self.btn.config(state="disabled"); self.log.delete("1.0", "end"); self.pb["value"] = 0
threading.Thread(target=self.worker, args=(port,), daemon=True).start()
def worker(self, port):
try:
do_flash(port, lambda m: self.q.put(("log", m)), lambda f: self.q.put(("prog", f)))
except Exception as e:
self.q.put(("log", "[ОШИБКА] " + str(e)))
self.q.put(("done", None))
def poll(self):
try:
while True:
k, v = self.q.get_nowait()
if k == "log": self.logline(v)
elif k == "prog": self.prog(v)
elif k == "done": self.busy = False; self.btn.config(state="normal")
except queue.Empty:
pass
self.root.after(60, self.poll)
if __name__ == "__main__":
root = tk.Tk()
try:
App(root); root.mainloop()
except Exception as e:
import traceback; traceback.print_exc()
'''
gui = GUI.replace("__B64__", b64).replace("__INFO__", info)
os.makedirs(DIST, exist_ok=True)
gui_path = f"{DIST}/flasher_gui.py"
open(gui_path, "w", encoding="utf-8").write(gui)
print("wrote", gui_path, len(gui), "bytes")
cmd = [sys.executable, "-m", "PyInstaller", "--onefile", "--windowed",
"--name", "Ru-4D_Flasher", "--distpath", DIST,
"--workpath", f"{DIST}/build", "--specpath", f"{DIST}/build",
"--clean", "--noconfirm", gui_path]
print("running:", " ".join(cmd[2:]))
r = subprocess.run(cmd, capture_output=True, text=True)
print(r.stdout[-1500:]); print(r.stderr[-1500:])
print("EXIT", r.returncode)
+110
Просмотреть файл
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Generate a single-file WebSerial flasher (flasher.html) with the firmware
embedded. Open in Chrome/Edge, one button flashes the RT-4D."""
import base64, os
ROOT = "C:/Users/vikto/Documents/Claude/rt-4d"
FW = f"{ROOT}/stock-fw/rt4d_ru_full.bin"
OUT = f"{ROOT}/dist/Ru-4D_flasher.html"
fw = open(FW, "rb").read()
b64 = base64.b64encode(fw).decode()
HTML = """<!doctype html><html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ru-4D — Прошивка</title>
<style>
:root{--a:#2b7;--b:#0a0e14;--c:#131a24;--t:#dfe7ef;--m:#7f8ea3}
*{box-sizing:border-box}
body{margin:0;background:var(--b);color:var(--t);font:15px/1.5 system-ui,Segoe UI,Roboto,sans-serif;
min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
.card{background:var(--c);border:1px solid #223;border-radius:16px;max-width:520px;width:100%;
padding:26px 26px 22px;box-shadow:0 10px 40px #0008}
h1{margin:0 0 2px;font-size:22px}
.ver{color:var(--m);font-size:13px;margin-bottom:18px}
ol{margin:0 0 18px;padding-left:20px;color:#c3cdd9}
ol b{color:var(--t)}
button{width:100%;border:0;border-radius:12px;background:var(--a);color:#04120b;font-weight:700;
font-size:18px;padding:15px;cursor:pointer;transition:.15s}
button:hover{filter:brightness(1.08)} button:disabled{opacity:.5;cursor:default}
.bar{height:12px;background:#0c1219;border-radius:8px;overflow:hidden;margin:16px 0 8px;display:none}
.bar>i{display:block;height:100%;width:0;background:var(--a);transition:.15s}
#log{white-space:pre-wrap;font:12px/1.5 ui-monospace,Consolas,monospace;color:#9fb0c2;
background:#0c1219;border-radius:10px;padding:10px 12px;margin-top:12px;max-height:170px;overflow:auto;display:none}
.ok{color:#3d9} .err{color:#f77}
.warn{background:#3a2410;border:1px solid #6a3;color:#fc9;border-radius:10px;padding:10px 12px;font-size:13px;margin-bottom:14px;display:none}
</style></head><body>
<div class="card">
<h1>Ru-4D — Прошивка рации</h1>
<div class="ver" id="ver"></div>
<div class="warn" id="nowebserial">Откройте этот файл в <b>Google Chrome</b> или <b>Microsoft Edge</b> (нужен WebSerial).</div>
<ol>
<li><b>Выключите</b> рацию.</li>
<li>Включите её, <b>удерживая клавишу «*»</b> (режим прошивки).</li>
<li>Подключите рацию к ПК кабелем USB-C.</li>
<li>Нажмите <b>«Прошить»</b> и выберите COM-порт рации.</li>
<li><b>Не отключайте</b> кабель до конца записи.</li>
</ol>
<button id="go">Прошить</button>
<div class="bar" id="bar"><i></i></div>
<div id="log"></div>
</div>
<script>
const FW_B64="__B64__";
const FW_INFO="__INFO__";
document.getElementById('ver').textContent=FW_INFO;
const SEED=0x48, ACK=0x06;
const $=id=>document.getElementById(id);
const logEl=$('log'), barEl=$('bar'), barI=barEl.querySelector('i'), btn=$('go');
function log(m,c){logEl.style.display='block';const s=document.createElement('span');if(c)s.className=c;s.textContent=m+"\\n";logEl.appendChild(s);logEl.scrollTop=logEl.scrollHeight;}
function prog(f){barEl.style.display='block';barI.style.width=Math.round(f*100)+'%';}
function b64ToBytes(b){const s=atob(b),a=new Uint8Array(s.length);for(let i=0;i<s.length;i++)a[i]=s.charCodeAt(i);return a;}
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function cksum(a){let s=SEED;for(const b of a)s=(s+b)&0xFF;return s;}
class Port{
constructor(p){this.p=p;this.q=[];this.run=false;}
async open(){await this.p.open({baudRate:115200});this.r=this.p.readable.getReader();this.w=this.p.writable.getWriter();this.run=true;this._loop();}
async _loop(){try{while(this.run){const{value,done}=await this.r.read();if(done)break;if(value)for(const b of value)this.q.push(b);}}catch(e){}}
async write(a){await this.w.write(new Uint8Array(a));}
flush(){this.q.length=0;}
async readByte(ms){const e=Date.now()+ms;while(Date.now()<e){if(this.q.length)return this.q.shift();await sleep(2);}return null;}
async close(){this.run=false;try{await this.r.cancel();}catch(e){}try{this.r.releaseLock();this.w.releaseLock();}catch(e){}try{await this.p.close();}catch(e){}}
}
async function doFlash(){
const fw=b64ToBytes(FW_B64);
let port;
try{port=new Port(await navigator.serial.requestPort());}catch(e){log('Порт не выбран.','err');return;}
try{
await port.open();
log('Порт открыт. Синхронизация с бутлоадером...');
port.flush();let ready=false;const end=Date.now()+7000;
while(Date.now()<end){const b=await port.readByte(150);if(b===null){await port.write([0xFF]);}else if(b===0xFF){ready=true;break;}}
if(!ready)throw new Error('Рация не отвечает. Выключите и включите её с зажатой «*», затем повторите.');
log('Бутлоадер найден. Стирание...');
for(const trig of [0x10,0x55]){const p=[0x39,0x33,0x05,trig];p.push(cksum(p));port.flush();await port.write(p);
let ok=false;const e2=Date.now()+4000;while(Date.now()<e2){if(await port.readByte(200)===ACK){ok=true;break;}}if(!ok)throw new Error('Ошибка стирания.');}
log('Запись прошивки...');
const total=Math.ceil(fw.length/1024)*1024;const data=new Uint8Array(total);data.fill(0xFF);data.set(fw);
for(let off=0;off<total;off+=1024){const chunk=data.subarray(off,off+1024);
const p=[0x57,(off>>8)&0xFF,off&0xFF];for(const b of chunk)p.push(b);p.push(cksum(p));
port.flush();await port.write(p);
let ok=false;const e3=Date.now()+3000;while(Date.now()<e3){if(await port.readByte(100)===ACK){ok=true;break;}}
if(!ok)throw new Error('Ошибка записи на 0x'+off.toString(16)+'.');
prog((off+1024)/total);}
prog(1);log('✅ Готово! Выключите и включите рацию обычно.','ok');
}catch(e){log(''+e.message,'err');}
finally{try{await port.close();}catch(e){}}
}
if(!('serial'in navigator)){$('nowebserial').style.display='block';btn.disabled=true;}
btn.onclick=async()=>{btn.disabled=true;logEl.innerHTML='';try{await doFlash();}finally{btn.disabled=false;}};
</script></body></html>"""
info = f"Прошивка Ru-4D V3.25 (русифицированная) · {len(fw)} байт"
html = HTML.replace("__B64__", b64).replace("__INFO__", info)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
open(OUT, "w", encoding="utf-8").write(html)
print(f"firmware {len(fw)} B -> base64 {len(b64)} chars")
print(f"wrote {OUT} ({len(html)} bytes)")