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 удалений
+229
Просмотреть файл
@@ -0,0 +1,229 @@
# RT-4D Menu Selection → Inverse-Video Highlight — RE Findings & Patch Spec
Firmware: `rt4d_stock_v3.25_abs_0x08002800.bin` (ARM Cortex-M4F Thumb, load base `0x08002800`, size 155740 = 0x2605C).
All addresses are **virtual** (vaddr). File offset = vaddr 0x08002800.
---
## 1. Render pipeline (as reverse-engineered)
### 1.1 Text / glyph layer (confirmed, trusted)
- `draw_string` **@0x08008A50**. Signature: `(r0=y_page, r1=x_pixel, r2=char*, r3=len, [sp,#0x28]=mode)`.
The `mode` word at `[sp,#0x28]` is loaded (`ldr r3,[sp,#0x28]`) and passed as `r3`/`r7`
to the glyph blitters. Advance is +7px per ASCII char (`adds r0,r6,#7`).
- ASCII glyph blitter **@0x08007FB8**, CJK blitter **@0x08008454**. Both copy a 14-byte
(7 cols × 2 pages, **column-major**, 8 vertical px/byte) glyph into a stack buffer via
`0x08021828`, then transform by **mode (r7)** before blitting:
- **mode 0** → glyph copied as-is → **normal** (black text on clear background).
- **mode 1** → `mvns` every byte (full invert) **+ edge masks**: even byte `&=0xFE`,
odd byte `&=0x7F`. → **INVERSE VIDEO**: the whole 7×16 cell becomes a white bar with the
glyph punched black, leaving a 1px gap top+bottom for clean row separation.
*Verified by simulation:* `mode1(empty cell)=FE 7F FE 7F…` (solid bar w/ 1px gaps);
`mode1(solid px)=00` (black). This is exactly a modern "highlight bar with readable text".
- **mode 2** → odd bytes `|=0x80` → sets the bottom pixel of the top page → a thin
**underline / bottom rule** (used for title bars / section headers), NOT a full highlight.
- mode ≥3 → falls through to normal (mode 3/4 seen in the home screen = plain text).
- LCD framebuffer primitives (128×64 mono, ST7565/UC1701 class, page-addressed over SPI):
- `lcd_set_addr(r0=x, r1=y)` **@0x08014A7C** — maps `x → (0xB7x)`, sets page/column.
- `lcd_write_col(r0=byte)` **@0x08014B28** — writes one 8-px vertical column, auto-advances.
- `lcd_flush()` **@0x08014CB8** — DMA/SPI blit of the composed frame (calls SPI `0x08004CE0`).
- Line geometry: 7px pitch → **18 chars per 128px line**; y is a **page index 0..7**
(menu text is drawn at page/`y=4`).
### 1.2 Menu system architecture (retained-mode, staged buffers)
The settings menu (`Basic Set`, `Key Define`, `Analog Set`, `Digital Set`, `Channel Set`,
`Zone Set`, `Message`, `Device Name`, … full descriptor/label table at **0x0801521C–0x08015440**,
`[submenu/handler ptr][16-byte fixed label]` records) is **not** drawn by a single visible
row loop. Instead it is retained-mode:
- **Widget-setup** helpers stage a menu descriptor into RAM state struct **@0x20000CE8**
(fields: `+1`=widget type, `+4/5`=item count, `+2`=selected index, `+6`=sel, `+7`=flags)
and label/value buffers **@0x20000A83** (`+0x15`=current-item text, `+0x27`=next-item text,
`+0x17`=inline-edit buffer, `+0x2a`=split/cursor position). Setup entry points:
`0x08009C58` (generic selectable list), `0x0800B0B4` (numeric value), `0x0800B100`,
`0x0800B8D8`, `0x08009D3C`.
- **List refresh** `0x0801CB10` computes `sel` and `(sel+1)%count`, copies the selected
item into the "current" slot (`+0x15`) and the following item into the "next" slot (`+0x27`)
— i.e. a 2-line window with the **selected item always in the top ("current") slot**.
- **Screen paint / blit** happens in the home/menu render dispatcher `0x080142C0`, which
`tbb`-dispatches (`@0x08014348`, on mode byte `[struct-1 +0x16]`, cases 0–6) to 7 small
widget painters, all drawing at `y=4`:
- case 0 → **`0x08014074`** (inline field / list-item painter) — see §2.
- cases 1–6 → `0x080143A0 / 0x080143E6 / 0x08014442 / 0x080144DE / 0x08014514 / 0x0801435A`
(inline value editors: split a value into segments, draw the **edited segment with mode 1**
and the rest with mode 0).
- The big per-item value screen `0x08014E20` (called from `0x0800ABD0` / `0x0800B054`) is the
submenu value/edit dispatch; it feeds text through `0x08009C58` (×8) rather than drawing
directly.
**Menu-list RENDER routine answer (task item 1):** the visible menu row/field is drawn by
**`0x08014074`** (dispatched from the render loop `0x080142C0` via the `tbb` @0x08014348).
`0x08014074` is the *only* function in the image that both references the menu text buffer
(`0x20000A83`) **and** calls a glyph blitter — it is the concrete draw site to patch.
---
## 2. How the current selection is drawn (task item 2)
`0x08014074` (reads state struct `0x20000A83`; `L = [+0x2a]` = split/caret position 0..0x10;
`buf = +0x17` = 17-char item text buffer). All draws at `y=4`. Decoded:
```
if [+0x2a] >= 0x11: ; buffer full — no caret
draw_string(y=4, x=1, buf, len=0x11, mode=0) ; whole line, normal
else:
draw_string(y=4, x=1, buf, len=L, mode=0) ; text BEFORE the cursor (normal)
draw_string(y=4, x=L*7+1, buf+L, len=1, mode=1) ; the SELECTED char (INVERSE) ← cursor
draw_string(y=4, x=L*7+8, buf+L+1, len=0x10-L, mode=0) ; text AFTER the cursor (normal)
```
So today the "cursor / selection indicator" is **a single character rendered in mode-1
inverse video** (a 1-char-wide highlight caret), positioned at column `L`. The special
treatment of the selected index is the middle `draw_string` call with **`len=1, mode=1`**
(instruction sequence: `movs r0,#1 ; str r0,[sp]` sets mode=1; `movs r3,#1` sets len=1).
The inline value-editor widgets (cases 1–6 of `0x080142C0`) work the same way, inverting the
*segment* currently being edited.
There is **no `>`/triangle glyph and no separate arrow bitmap** — the "arrow/left-cursor"
the UI shows is this inverse caret block. (The only bitmap-cursor-like helper, `0x08008224`,
is the battery/RSSI icon drawer, unrelated.) So "remove the arrow" = "stop drawing the
1-char inverse caret and instead inverse the *entire* selected line".
---
## 3. Cleanest way to a full-width inverse highlight (task item 3)
Two mechanisms exist; mode-1 is the right one (mode 2 is only an underline):
- **(a) Draw the selected row's full text with mode 1** and **pad the string to the full 18-col
line width** so the highlight bar spans edge-to-edge. Because mode-1 inverts each *cell*
(including the space glyph → solid bar with 1px gaps), a right-padded string already yields
a full-width readable highlight bar — **no separate rectangle-fill routine is required.**
- **(b) Fill/invert-rect helper:** the image has **no general "invert rectangle" routine**;
the only rect-ish primitive is the icon column-writer `0x08008300`/`0x08008224` (fixed
14-col templates). Re-purposing it is more invasive than (a). So **approach (a) is chosen.**
For the settings-list specifically the item text is staged into buffers padded with spaces
already (buffers are `memset`-filled to 0x10 with `0x20`/blanks by `0x080062EC` before the
label copy), so a mode-1 draw of the current-slot buffer paints the whole row as a bar.
---
## 4. Concrete patch (task item 4)
### Approach: minimal, in-place, length-safe — switch the selected row's whole draw to mode 1
The selected line is the **top / "current" slot** of `0x08014074`. Replace the 3-segment
(normal | inverse-caret | normal) draw with **one full-width mode-1 draw of the whole buffer**.
This makes the *entire selected line* an inverse highlight bar and eliminates the 1-char caret.
`0x08014074` prologue+body bytes (for reference, from offset 0x11874):
```
0x08014074: 38 b5 22 48 90 f8 2a 00 11 28 09 db 00 20 11 23 ; push; ldr r0,=struct; ldrb r0,[r0,#0x2a]; cmp #0x11; blt; movs r0,#0; movs r3,#0x11
0x08014084: 1e 4a 17 32 01 21 00 90 04 20 f4 f7 df fc 34 e0 ; ldr r2,=struct; adds r2,#0x17; movs r1,#1; str r0,[sp]; movs r0,#4; bl draw_string; b .+
0x08014094: 00 20 00 90 19 48 90 f8 2a 30 00 f1 17 02 01 21 ; movs r0,#0; str r0,[sp](mode=0); ...; movs r1,#1
0x080140a4: 04 20 f4 f7 d3 fc 01 20 00 90 14 48 90 f8 2a 30 ; movs r0,#4; bl draw_string; movs r0,#1; str r0,[sp](mode=1) ← caret
...
```
The `blt` at `0x0801407E` (`11 28 09 db`: `cmp r0,#0x11 / blt`) already selects between the
"full buffer" branch (`0x08014080`, draws the whole 17-char buffer at `x=1,y=4`) and the
"3-segment caret" branch (`0x08014094`). **The simplest robust change is: make the whole-buffer
branch use mode 1, and force execution down that branch always** (skip the caret path). That
gives a full-line inverse highlight for the item and removes the caret entirely.
**Patch — 2 sites, 4 bytes total, no code cave, no length change.**
The full-buffer branch at `0x08014080` draws `draw_string(y=4, x=1, buf, len=0x11, mode=[sp])`.
We (P1) force that branch to always run and (P2) make its mode = 1.
**P1 — force the full-buffer / highlight branch.** Remove the `blt` that would otherwise divert
to the 3-segment caret path, so the full-buffer draw at `0x08014080` always executes:
- vaddr **`0x0801407E`**: original `09 DB` (`blt #0x08014094`) → new **`00 BF`** (`nop`). (2 bytes)
**P2 — make that draw inverse.** The branch sets its mode via `movs r0,#0 ; str r0,[sp]`:
- vaddr **`0x08014080`**: original `00 20` (`movs r0,#0`) → new **`01 20`** (`movs r0,#1`). (2 bytes)
Verified patched disassembly:
`cmp r0,#0x11 ; nop ; movs r0,#1 ; movs r3,#0x11 ; ldr r2,=buf ; adds r2,#0x17 ; movs r1,#1 ; str r0,[sp] ; movs r0,#4 ; bl draw_string`
→ draws the full 17-char (space-padded) buffer at x=1,y=4 in **mode 1**; the caret path at
`0x08014094` is now dead code.
Result: whenever this widget paints, it draws the full 17-char (space-padded to 18-col line)
buffer at `x=1, y=4` in **mode 1 = full-width inverse-video highlight bar**, and the old
single-char inverse caret path (`0x08014094…`) is never reached → **arrow/caret removed**.
**Final patch list — (vaddr, file_offset, original_bytes, new_bytes):**
```
0x0801407E (off 0x1187E) : 09 DB -> 00 BF ; blt 0x8014094 -> nop
0x08014080 (off 0x11880) : 00 20 -> 01 20 ; movs r0,#0 -> movs r0,#1 (mode 0 -> 1)
```
Bytes are shown in stored (file) order. Total change: 4 bytes, in place, no length change.
### Optional wider fix (cases 1–6 / other menus)
The same 1-char-inverse→full-line-inverse idea applies to the inline value-editor widgets
`0x080143A0…0x08014514` and the list refresh `0x0801CB10`. Those are **out of scope for a
minimal, low-risk patch** (each edits distinct value fields where a per-segment caret is
actually desirable). Recommend shipping only the `0x08014074` change first, verify on-radio,
then decide whether the value-editors should also flip.
### Alternative (code-cave) approach, if per-row control is wanted
If you later want the highlight on a scrolling **multi-row** list (rather than the single
current-item slot), a code cave is available:
- 0xFF-erased cave: **`0x08024AD2`, 320 bytes free**.
- 0x00 cave: **`0x08024778`, 336 bytes free**.
A small Thumb helper could loop rows, calling `draw_string(y=row_page, x=1, row_text, 18,
mode = (row==sel)?1:0)`, then hook it in place of the `bl 0x08014074`. Not needed for the
minimal fix above.
---
## 5. Residual risks & on-radio verification
**Risks**
1. **Buffer padding**: the full-line branch draws `len` = the buffer count. If the item text
isn't space-padded to the full 18 columns in *every* menu that reaches `0x08014074`, the
highlight bar will only span the text, not the whole line. Mitigation: the setup helpers
`memset` the buffers to blanks (0x20) to width 0x10 before copying the label, so padding is
generally present; confirm visually. If a bar is short, extend the draw len to 18 and ensure
trailing spaces.
2. **Shared painter**: `0x08014074` (case 0) may also render non-list inline fields (e.g. a
name/DTMF entry field) where the single-char caret was intentional. Forcing full-line
inverse there removes the per-char caret — acceptable for a "selected line" look but check
text-entry screens remain usable (you lose the char-position caret). If that regresses a
text-entry screen, gate the change on the widget-type byte instead of nop-ing the `blt`.
3. **1px row gaps**: mode-1 edge masks leave 1px clear at top and bottom of the cell — this is
desirable (separates rows) and matches modern radios; no action needed.
4. **Checksum/signature**: if the loader validates a firmware CRC/signature, patched bytes must
be re-CRC'd. v3.25 is an absolute image at 0x08002800 — verify whether the bootloader checks
an appended checksum before flashing.
**Verification on-radio**
1. Flash patched image. Enter **Menu**. The currently-highlighted item should show as a solid
inverse bar (white background, black text) spanning the line width; the old 1-char
arrow/inverse caret should be gone.
2. Scroll up/down: the highlight bar must follow the selection (top "current" slot) and text
stays readable at every position.
3. Enter a submenu with a numeric value (e.g. **Backlight / Light Timer**) — confirm value
screens still render (those go through `0x08014E20`, unaffected).
4. Open a **text-entry** screen (Device Name / Message) — confirm it's still operable
(risk #2). If the editing caret is needed there, switch to the type-gated variant.
5. Watch for any garbled top line at boot/home screen (shared render dispatcher `0x080142C0`)
— the patch only alters case-0 widget, home layout should be unchanged.
---
## Address quick-reference
| what | vaddr |
|---|---|
| draw_string | 0x08008A50 |
| ASCII glyph blitter (mode in r7) | 0x08007FB8 |
| CJK glyph blitter | 0x08008454 |
| lcd_set_addr / lcd_write_col / lcd_flush | 0x08014A7C / 0x08014B28 / 0x08014CB8 |
| menu descriptor/label table | 0x0801521C–0x08015440 |
| list-widget setup (generic) | 0x08009C58 |
| list refresh (current/next slot) | 0x0801CB10 |
| screen render dispatcher (tbb) | 0x080142C0 (tbb @0x08014348) |
| **selected-row painter (PATCH SITE)** | **0x08014074** |
| menu state struct / text buffers (RAM) | 0x20000CE8 / 0x20000A83 |
| code caves | 0x08024AD2 (320B, 0xFF) / 0x08024778 (336B, 0x00) |