Files
rt-4d/docs/UI-Architecture.md
T
viktorиClaude Opus 4.8 ae36c3b729 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

329 строки
29 KiB
Markdown
Исходник Ответственный История

Этот файл содержит неоднозначные символы Юникода
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RT-4D Stock Firmware — UI Architecture & API Reference (for a UI rewrite)
**Target image:** `stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin` — ARM Cortex-M4F (STM32F407-class Artery AT32F407 clone), Thumb, load/vaddr base **`0x08002800`**. App flash spans `0x08002800 .. 0x0802885C` (~152 KB). All disassembly via capstone `CS_ARCH_ARM + CS_MODE_THUMB`.
This is the **master blueprint** for rewriting the RT-4D's entire UI (standby screen + menu system + all screens + navigation) while **reusing** the stock firmware's display/keys/RF/DMR/codeplug functions as an SDK, and while keeping the **SPI codeplug format** and the **CPS serial protocol** byte-identical so the stock Radtel CPS keeps working. It synthesizes seven subsystem RE docs (`re/main-loop.md`, `re/display.md`, `re/input.md`, `re/radio.md`, `re/dmr.md`, `re/codeplug.md`, `re/main-screen.md`), resolves their contradictions, and preserves every concrete vaddr/signature.
---
## 1. Executive summary
**How the UI is structured.** Boot glue hands off to the application superloop **`app_main @0x0802136C`**. Each pass, the superloop honours a reboot flag, then branches on **serial-session state**: if the radio is *not* in a PC-programming / SPI-write session it runs the **normal UI tick `ui_tick_normal @0x080207DC`**; otherwise it services the CPS/serial paths. The normal UI tick is a cooperative scheduler that, among other periodic tasks, calls exactly two dispatchers:
- **Screen draw:** `ui_draw_dispatch @0x0801E1BC` (called at **`0x080207E6`**) — `tbb`-jumps on the single screen-state byte `g_screen @0x200008B3` (0..11) to the current screen's incremental draw routine.
- **Screen input:** `ui_process_key @0x0801E6EC` (called at **`0x08020816`**) — first calls `keypad_process @0x08005A14` (scan+debounce), then `tbb`-jumps on the same `g_screen` to the current screen's key handler `handler(u8 keycode, u8 keystate)`.
Both dispatchers are keyed on **the same** state byte and their 12-entry `tbb` tables are positionally parallel (index *i* = same screen in both). Drawing is **incremental / dirty-flag driven** into a page-addressed 128×64 mono LCD with **no framebuffer** (pixels stream straight to GDDRAM).
**Single best hook strategy.** Patch the **two `bl` call sites in the scheduler**`0x080207E6` (draw) and `0x08020816` (key) — to call your own router (`my_draw_router` / `my_key_router`) living in free app flash. Your router owns rendering + navigation and calls the stock lower-level APIs for everything else. This is a **2-instruction redirect** that leaves the superloop, the RF/DMR RX pipeline, battery/scan tasks, and the **entire serial/CPS + SPI region-write path untouched**. (See §4 for exact patch encoding and a phased plan.)
**CPS-compatibility boundary.** Two things must stay byte-identical: (a) the **on-SPI codeplug format** (region bases, record strides/fields, settings dual-bank + `0xABCD` magic, per-unit calibration at `0x000000`), and (b) the **USART6 CPS protocol** (ISR `0x0802061C`, framer `0x0801F864`, dispatcher `0x08019790`, region R/W handler `0x080188D4`, its buffers, and all opcodes). The UI never touches these directly — it mutates the **RAM working copies** of settings/channels and calls the stock **save wrappers**, which preserve the format. Because the CPS transfers raw SPI blocks, preserving the on-flash format automatically preserves CPS compatibility. A third internal bus (USART3 ↔ FM100B DMR baseband, "ATC" `0x68…0x10` framing) carries all RF/DMR programming and is independent of both boundaries — the UI reuses it via the RF/DMR wrappers.
---
## 2. Memory & architecture map
| Region | Range / value | Notes |
|---|---|---|
| Bootloader flash | `0x08000000 .. 0x08002800` | separate image; CPS bootloader opcodes `0x39`/`0x57`. Do not touch. |
| **App flash** | `0x08002800 .. ~0x0802885C` | this image (~152 KB). Vector base set into VTOR by `app_main`. |
| **Free app flash** | **`0x08029000+`** (and gaps at `0x08028860/0x08028900`, already used by russification cave + font) | inject the custom UI layer here (§4.4). |
| SRAM | `0x20000000 .. 0x20020000` (128 KB), initial SP `0x2000AE48` | UI state vars live in `0x2000xxxx`. |
| **External SPI data-flash** | 4 MB (dumped in `radio-spi-dump.bin`) | codeplug + calibration + fonts. Read via `spi_flash_read @0x08021828` (opcode 0x03). **Codeplug format frozen.** |
| MCU peripherals | RCC `0x40023800`; USART1 `0x40010000`, USART2 `0x40004400`, **USART3 `0x40004800` (FM100B DMR)**, **USART6 `0x40011400` (PC/CPS)**; SPI2 `0x40003800`; ADC1 `0x40012000` (batt/RSSI); DAC `0x40007400` (audio); TIM1/2/9/10; GPIOA–F | TIM2 ISR `@0x0801DDCD` = tick; SysTick unused. |
| Display | mono **128×64**, 8 pages × 128 cols, **bit-banged GPIO** (RS `0x40010000`, CLK/DATA GPIOB `0x40020400`, CS GPIOA `0x40020000`), **no framebuffer** | fonts fetched from SPI `0x19C000` (ASCII 14 B/glyph) / `0x19E000` (GBK 28 B/glyph). |
**Three independent buses (keep them straight):** USART6 = CPS (frozen), USART3 = FM100B/DMR (reuse via ATC wrappers), SPI2/GPIO SPI = codeplug+fonts (format frozen; reads OK). The UI rewrite touches only the LCD GPIO and reads.
---
## 3. The main loop & screen state machine — exact hook points
### 3.1 Boot → superloop (verified)
```
Reset 0x08002AC0 → SystemInit(0x0801DA2D) → __main 0x080029E0 → __rt_entry 0x08002AA0
→ bl 0x0802136C ; app_main — the application superloop
app_main 0x0802136C:
hw_init(0x0801DAD0); VTOR=0x08002800; init B/C/D/E; then LOOP:
0x0802138C if (*(u8*)0x20000C54) nvic_system_reset(0x0801A38C) ; reboot flag
0x08021396 if (mode 0x20000B66==0 && 0x20000C12∉{2,4} && 0x20000C57==0)
0x080213B2 bl 0x080207DC ; ★ NORMAL UI TICK (ui_tick_normal)
else … CPS / SPI-write session handlers … ; ← DO NOT TOUCH
0x0802140C bl 0x0801F854 (serial_poll) ; every pass — CPS path — keep
0x08021410 bl 0x0801FE50 (housekeeping) ; every pass — keep
0x08021414 loop
```
### 3.2 `ui_tick_normal @0x080207DC` — the two dispatch calls to hook (verified by disasm)
```
0x080207E6 bl 0x0801E1BC ; ★ ui_draw_dispatch (SCREEN DRAW — hook here)
0x08020816 bl 0x0801E6EC ; ★ ui_process_key (SCREEN INPUT — hook here)
```
Both are unconditional every UI pass. Other scheduler calls (`0x0801E050` side-key handler, `0x0801E3F8` line-buffer builder, and period-gated RSSI/battery/scan tasks throttled by counters `0x20000BF1..BF8`) can be left as-is or repointed for full control.
### 3.3 The screen state machine (single selector + two mirror `tbb` tables)
```
g_screen = *(u8*)0x200008B3 ; current screen id, 0..11 — 55 literal-pool xrefs
g_screen_prev = *(u8*)0x200008B2 ; previous screen (back/restore)
```
Both dispatchers guard `cmp g_screen,#0x0C; bhs default` before the `tbb`. Draw-table base `@0x0801E1CE`, input-table base `@0x0801E784` (each 12 one-byte half-word offsets). **Navigation = write the target id to `0x200008B3`** (+ per-screen enter side-effects); optionally save old id to `0x200008B2`.
| id | draw handler | input handler | screen |
|---:|---|---|---|
| **0** | `0x08013BD8` | `0x08017F60` | **Home / standby (VFO/channel)** — primary |
| 1 | default no-op | default no-op | blank / transient |
| 2 | `0x08013980` | `0x08007B10` | VFO/standby-key screen (MENU key → id 10) |
| 3 | `0x08013A44` | `0x0800FE3C` | freq-input / dial (icon `0x0802505C`) |
| 4 | `0x08014978` | `0x0802074C` | screen 4 (icon `0x08025115`) |
| 5 | `0x08013940` | `0x08007784` | numeric entry A (hex 0–F) |
| 6 | `0x080139EC` | `0x08007CD0` | numeric entry B (dec 0–9) |
| 7 | `0x08013804` | `0x08007E68``0x08017F60` | alt/dual home (variant of 0) |
| 8 | `0x08013B6C` | via `0x0800F930` | list/scroll screen |
| 9 | `0x08013F7C` | no-op | status/info screen |
| **10 (0xA)** | **`0x080142C0`** | **`0x08017294`** | **MENU system** ★ |
| 11 (0xB) | `0x08014104` | `0x0801D3B0` | SMS / text editor |
Canonical transition (Home→Menu, in id-2 handler): `bl 0x0801AD9C` (build top-level list) → `movs r0,#0xA; strb r0,[=0x200008B3]`.
### 3.4 Contradiction resolved — there are TWO dispatch systems; only one is the live UI
`re/input.md` documents a **second** router, `screen_dispatch @0x08018DF4`, keyed on `ctx[+1] @0x20002121` (0..5), reached from `ui_input_service @0x08005E54`. Verified xrefs settle which is authoritative:
- `ui_tick_normal @0x080207DC` is called from the superloop (`0x080213B2`) and calls **`ui_process_key @0x0801E6EC`** at `0x08020816`; `ui_process_key` itself calls `keypad_process @0x08005A14`. **This `g_screen`/`0x200008B3` path (12 screens) is the LIVE normal-mode UI.**
- `ui_input_service @0x08005E54` has **exactly one caller, `0x08010D08`**, which sits behind a blocking `delay_ms(0xBB8)` — a **special sub-mode / config context**, not the normal tick. Its router `screen_dispatch @0x08018DF4` (also called from `0x0800A8D8`, `0x080172CE` inside menu code) drives a smaller **5-entry** secondary state machine used by that sub-mode and certain menu-internal editors.
**Conclusion for the rewrite:** hook the **primary** path (`0x080207E6` / `0x08020816`, `g_screen @0x200008B3`). The secondary `screen_dispatch`/`ctx @0x20002121` machine is real but subordinate; if a screen you replace routes into it, override that screen's handler too. Both use the **same key struct `0x20000B57`** (byte +2 = keycode, +3 = keystate), so key reads are uniform.
---
## 4. Callable API reference — the "SDK" a new UI links against
All addresses are absolute Thumb vaddrs; when taking a function pointer, set bit0. AAPCS: r0..r3 = args, extra args on stack.
### 4.1 Display (reuse verbatim — the graphics toolkit)
Panel: 128×64 mono, 8 pages × 128 cols, 1 bpp, **no framebuffer** (immediate). "Row" = 2 pages (16 px). `mode`: 0 normal / 1 inverse (selection highlight) / 2 outline.
| vaddr | name | C signature | notes |
|---|---|---|---|
| **`0x08008A50`** | **draw_string** | `void draw_string(u8 page, u8 x, const char *s, u16 len, u8 mode /*[sp+0x28]*/)` | **primary text API.** ASCII (+7px) & GBK/CJK/Cyrillic (lead≥0x80, +14px) auto-routed; wraps X~121. |
| `0x08007FB8` | ascii_blit | `void(u8 page,u8 x,u8 ch,u8 mode)` | one 7×16 ASCII glyph; SPI font `0x19C000+(ch-0x20)*14`. |
| `0x08008454` | gbk_blit | `void(u8 page,u8 x,u16 gbk,u8 mode)` | one 14×16 double-byte glyph; SPI font `0x19E000`. |
| `0x08008530` | gbk_char_at | `void(u8 page,u8 x,u16 gbk)` | standalone wide-char draw. |
| `0x080089AC` | small_char | `void(u8 page,u8 x,char ch)` | compact 5×8 font (1 page). |
| `0x08008100` | big_char | `void(u8 page,u8 x,u8 ch,u8 mode)` | large freq digits (33 B/glyph). |
| `0x08008B90` | draw_number_row | `void(u8 page,u8 x,const u8*digits,u16 n,u8 mode)` | big-digit row, 12px stride (freq). |
| `0x08008BC6` | draw_str_spaced | `void(u8 page,u8 x,const u8*s,u16 n,u8 mode)` | small-font row, 6px stride. |
| `0x08008D24` | blit_cols | `void(u8 page,u8 count,const u8*cols,u8 src)` | 1-page raw blit; **`src=0` ⇒ clear** (0x00). |
| `0x08008D62` | blit_rect | `void(u8 page,u8 x,u8 pages,u8 width,const u8*bmp /*[sp]*/)` | multi-page bitmap/icon blit (use to flush an SRAM shadow). |
| `0x08008DAA` | draw_box | `void(u8 page_origin)` | rounded-rect popup/menu border. |
| `0x08008CF0` | draw_hline_seg | `void(u8 col,bool on)` | 2-px separator/underline. |
| `0x08008224` | draw_frame_corners | `void(u8 x,u8 page)` | selection frame corners. |
| `0x0800870C` / `0x08008874` | draw_marker5 / draw_icon_batt | `void(u8 page,bool on)` | A/B arrow (col 123) / battery marker. |
| `0x08008A04` | draw_icon_signal | `void(u8 page,bool on)` | signal icon (col 21). |
| `0x08014A7C` | lcd_set_pos | `void(u8 page,u16 col)` | cursor; `col_cmd = 0xB7 x`. |
| `0x08014B28` | lcd_write_col | `void(u8 column_bits)` | stream 8 vertical px (LSB=top), auto-advance. |
| `0x08014C34` | lcd_set_brightness | `void(u8 level)` | level 0..4 → PWM duty {0,5,0x1E,0x64,0xFF}. |
| `0x08021828` | spi_flash_read | `void(void*dst,u32 addr,u32 len)` | **font/codeplug read** (opcode 0x03) — reads only. |
| `0x08007946` | delay_ms | `void(u32 ms)` | busy delay. |
Clear full screen: loop `blit_cols(pg,128,0,0)` for `pg=0..7`. Selection highlight: `draw_string(..., mode=1)`.
### 4.2 Input (reuse — keypad/PTT)
Polled GPIO **4×4 matrix** (not ADC ladder, no rotary encoder). PTT = GPIOA pin12. Side keys = codes `0x11`/`0x12`.
| vaddr | name | C signature | notes |
|---|---|---|---|
| `0x0801130C` | keypad_decode | `u8(void)` | raw matrix word `0x20000B7C` → key code; `0xFF`=none. No debounce. |
| `0x08005A14` | keypad_process | `void(void)` | scan+debounce+long/repeat; fills key struct `KeyEv @0x20000B57`. Called by `ui_process_key`. |
| `0x08012C24` | key_event_clear | `void(void)` | ack/consume pending key (sets +2 to 0xFF). |
| `0x0801B294` | keypad_scan_col | `void(int col)` | scan one column into `0x20000B7C`. |
| `0x0801B398` | keypad_task | `void(void)` | column-drive state machine (call each ~1ms if you own the loop). |
| `0x08021316` | gpio_read_pin | `int(void*port,u32 mask)` | 1 if all masked IDR bits set. |
| `0x08021C6E` | gpio_write_pin | `void(void*port,u32 mask,int state)` | ODR set/clear. |
**Key struct `KeyEv @0x20000B57`:** +2 = delivered keycode (`0xFF`=none), +3 = keystate (1=press/short/long, 2=repeat), +5 = u16 hold-ticks (long/repeat threshold `0x2BC`=700), +0x1E (`0x20000B75`) = PTT flag. Stock consumes by writing `0xFF` to +2 after dispatch (`0x0801E806`).
**Key-code enum:** `0x00..0x09`=digits 0–9, `0x0B`=MENU/M, `0x0C`=UP, `0x0D`=DOWN, `0x0E`=`*`, `0x0F`=`#` (also menu-enter sentinel), `0x10`=EXIT, `0x11`=SIDE1, `0x12`=SIDE2, `0xFF`=none. (Non-digit label assignment is medium-high; confirm silk-screen on-device.)
### 4.3 Radio / RF (reuse — FM100B ATC layer). There is NO MCU-side RF chip; all RF/DMR lives in the FM100B, driven over USART3.
| vaddr | name | C signature | notes |
|---|---|---|---|
| **`0x0801AE9C`** | **radio_apply_channel** | `void(chan_cfg *cfg)` | ★ composite "tune the radio": pushes freq/mode/BW/power/CC/SQ/gains/ID/CTCSS to FM100B in one shot. **Call this after populating `cfg`.** |
| **`0x0800720C`** | **atc_channel_set** | `void(chan_cfg *cfg)` (msg 0x82) | ★ RX+TX freq + mode + bandwidth. Freq = codeplug 10 Hz units ×10 → Hz BE. `cfg[+5]`=RXfreq, `[+9]`=TXfreq, `[+1]`hi-nibble=modulation, `[+0]`bit1=narrow. |
| **`0x08007E78`** | **ptt_tx_start** | `void(u8 mode)` | ★ PTT on / start TX. mode 0=DMR, 1/2/3=analog/private/allcall. Sets band GPIO (GPIOA pin10). |
| `0x08006E6C` | atc_call_process | `void(u8 a,u8 type,u32 id,u8 r3)` (msg 0x06) | start call / key DMR TX (type 1=Priv,2=Grp,4=All). |
| `0x080071E2` | atc_ch_enable | `void(u8 rx,u8 tx)` (msg 0x62) | channel RX/TX enable. |
| `0x080074FA` | atc_set_radio_id | `void(u32 dmr_id)` (msg 0x2A) | set our DMR ID. |
| `0x080075F4` | atc_set_dig_squelch | `void(u8)` (msg 0x4D) | DMR squelch. |
| `0x08007548`/`0x08007598` | atc_set_color_code | `void(u8)` (msg 0x0C) | color code / off. |
| `0x0800736C` | atc_set_mute_code | `void(u16)` (msg 0x81) | analog DCS/mute value. |
| `0x08007404` | atc_set_rxgroup / read_group_list | `void(u8 idx)` (msg 0x84) | RX-group/CTCSS upload; also reads group list `0xC6000+idx*80`. |
| `0x08007530`/`0x0800760A` | atc_set_call_mic_gain / spk_vol | `void(u8)` (msg 0x0B / 0x02) | DMR mic gain / spk vol. |
| `0x08006C4C` | atc_set_denoise (a.k.a. dmr_set_radio_id in dmr.md) | `void(u8 tx,u8 rx)` (msg 0x49) | analog denoise / ID set — see §note. |
| `0x0801B044` | atc_send | `void(u8 id,u8 a1,u8 a2,u8 a3,u32 to)` | core no-payload sender; blocks on confirm. |
| `0x0801B0C4` | atc_send_pl | `void(u8 id,u8 a1,u8 a2,u8 a3,u8*pl,u16 len,u32 to)` | core payload sender; blocks on confirm. |
| `0x0801094C` | battery_read | `void(void)``0x200008B0` (0.1 V) | ADC1 battery. (`0x08010960` returns the value.) |
| `0x08020B4C` | adc_sw_start | `void(u32 port,u8 en)` | ADC software start. |
**RAM boundary object `main_settings` mirror:** `re/radio.md` uses `0x200029BB`, `re/codeplug.md` uses `0x20002014`. These are two named handles into the settings working area (offsets = `rt4d_codeplug.RadioSettings`); the RF apply reads settings fields from this mirror. Treat `0x20002014` as the canonical 4 KB working copy of SPI `0x002000`, and `0x200029BB` as a settings sub-region pointer used by the RF path; when in doubt, read/modify via the codeplug save wrappers (§4.5) so the on-flash format stays correct.
**msg 0x49 name conflict:** `re/radio.md` calls `0x08006C4C` `atc_set_denoise(tx,rx)`; `re/dmr.md` calls it `dmr_set_radio_id(idHi,idLo)`. Both agree it's a **4-byte payload cmd `0x49` sender**; the *semantics* are unresolved (LOW confidence). For an ID set, prefer the dedicated `atc_set_radio_id @0x080074FA` (msg 0x2A). Verify 0x49 on-target before relying on either name.
### 4.4 DMR (reuse — FM100B protocol over USART3, `0x68…0x10` framing; independent of CPS)
| vaddr | name | C signature | notes |
|---|---|---|---|
| `0x08003050` | poll_serial | `void(void)` | **pump**: run FM100B RX parse + CPS framer + RX drain once. Call in any wait loop. |
| `0x08018CB0` | fm100b_rx_parse | `int(void)` | consume one framed FM100B message, verify checksum, dispatch; returns 1 if consumed. |
| `0x08006348` | fm100b_on_frame | `void(u8*frame)` | master `*Cnf`/`*Ind` dispatch (sets `resp[cmd]`, jump-tables Inds). |
| `0x08006CFC` | dmr_call_start_from_contact | `void(u8 dummy,u16 contact_idx)` | originate call to stored contact (maps type, latches curcall). |
| `0x08006FD8` | dmr_call_resend | `void(void)` | re-send current-call setup (PTT continue). |
| `0x0800736C` | dmr_sms_send | `void(u16 target)` | send SMS (msg 0x82 header + 0x81 payload). |
| `0x08006D00` | dmr_contact_read | `void(u8 dummy,u16 idx,out u8 rec[21])` | read 21-B contact record `idx*27 + 0x5E000` (name/ID/type). |
| `0x080074FA` | atc_set_radio_id | `void(u32 dmr_id)` (msg 0x2A) | set own DMR ID (canonical). |
| `0x0801A38C` | nvic_system_reset | `noreturn void(void)` | reboot (remote-kill enforcement). |
**Incoming-call state (read to render RX overlay):** `0x20007DC2``+0` type(0=Grp,1=Priv,2=All), `+1` u32 dest/TG, `+5` u32 caller ID. Status bytes `0x20000C3C/3D`. Resolve caller **name** by ID via `find_contact_by_id @0x08005810` (fallback: decimal ID). `resp[cmd]` array `@0x20007476` (0xFF=pending). Convert on-wire BE IDs with `be32_to_u32 @0x080112B8`.
### 4.5 Codeplug / Settings (reuse — format-safe read/save; DO NOT re-implement)
Low-level SPI (reuse; addressing/opcodes baked in):
| vaddr | name | C signature |
|---|---|---|
| `0x08021828` | spi_flash_read | `void(void*dst,u32 addr,u32 len)` (opcode 0x03) |
| `0x08021924` | spi_flash_erase4k | `void(u32 sector_idx)` (opcode 0x20) |
| `0x08021A70` | spi_flash_program | `void(u32 addr,const void*src,u32 len)` (page-split) |
| `0x080217B8` | spi_page_program | `void(u32 addr,const void*src,u16 len)` (opcode 0x02, ≤256 B) |
| `0x080109DE` | checksum | `u8(const void*buf,u32 len)` (8-bit sum, seed 0) |
| `0x08010540` / `0x0801058C` | flash_write_guard_enter / _exit | `void(void)` (bracket erase/program batches) |
Per-record read/save wrappers (call these — they keep format + dual-bank correct):
| vaddr | name | C signature | commits/reads |
|---|---|---|---|
| `0x08004F20`/`0x080055A0` | read_channel | `void(u16 idx)` | `spi_flash_read(&liveChan[band], 0x4000+idx*48, 48)`; dest `0x20002DEA + band*48`. |
| `0x08005810` | find_contact_by_id | `bool(u32 id,u8 type,char*out_name16)` | scan contacts `0x5E000` (27-B stride). |
| `0x08007404` | read_group_list | `void(u16 idx)` | `0xC6000 + idx*80`; resolves members. |
| `0x08009B90` | read_addressbook_contact | `void(uint slot, out)` | `0x126000 + slot*32` (32-B). |
| `0x08009480` | read_message | `void(…)` | 200 B text → `0x20006F86`. |
| `0x08004CB0` | settings_save | `void(void)` | commit `main_settings`: guard → stage `0x2000``0x20002EBE` → erase sector 1 → program 4 KB → guard-exit. |
| `0x080061E0` | contacts_compact | `void(uint idx)` | format-safe contact delete. |
| `0x08004AB0` | codeplug_backup_to_shadow | `void(void)` | full "Backing up…" region backup. |
---
## 5. The main / standby screen — data sources + draw + replacement
**Screen id 0**, draw handler **`home_draw @0x08013BD8`**, input handler `0x08017F60`. (Alt/dual variant = id 7 → `0x08013804`.)
**Render pipeline (data-driven, two-stage):**
```
SPI channels (0x004000, 48B) → cached into g_chcache @0x20002DEA (48B stride, [area])
▼ format_area_display(u8 area,u8 hi,u8 shift) @0x08011414
│ reads cache freq/tones/mode/name → formats ASCII into g_disp
g_disp @0x200009C3 (display struct; Area A +0, Area B +0x43, stride 0x43)
│ + sets element dirty flags in g_dirty @0x200024EB
▼ home_draw @0x08013BD8 (every UI tick; paints element k iff g_dirty[k]!=0)
▼ draw_string / draw_number_row / draw_str_spaced / draw_marker5 → GDDRAM
```
**Key `g_disp @0x200009C3` fields:** `+0x00` top name/tag line; `+0x15` `"CH-nnn"`/`"A-"`/`"D-"` prefix; `+0x1C` status flag (`"HD"`); `+0x1E` element mode (0 blank/1 freq/2 name); `+0x20` main Area-A string (`"438.80000"`/name/`"CH MODE"`/`"VFO MODE"`); `+0x32` Area-B string; `+0x42` active area; `+0x63` A/B arrow marker. Companion `g_disp2 @0x20000A28` holds the `"ANA"`/`"DMR"` tag. Channel cache fields (`0x20002DEA+area*0x30`): `[+0]>>6`=digital, `[+5]`=RXfreq (MHz×100000), `[+9]`=TXfreq, `[+0xD]&0xFFF`=RX tone, `[+0x20]`=16-B name. DMR RX overlay ctx `g_call @0x2000A6C5` (`+1` type, `+2` ID, `+0x38` name).
**Helpers for formatting your own home screen:** `num_to_ascii(val,ndigits) @0x08018530` (→ scratch `0x200024D0`), `str_insert_char(buf,ch,pos,len) @0x080135A8` (splice the `.`), `memcpy_off(dst,src,dstoff,len) @0x080062EC`.
**Two replacement strategies:**
- **A (least work):** keep calling `format_area_display(area,hi,shift) @0x08011414` (does codeplug→ASCII math), then read the ready strings from `g_disp` and paint them in your own layout.
- **B (max control):** ignore `g_disp`; read the channel cache `0x20002DEA` + `g_call` directly, format with the helpers, paint with §4.1 primitives.
Both only **read** RAM caches and call display/ADC primitives — the codeplug format and CPS protocol are untouched.
---
## 6. DO-NOT-CHANGE list vs REUSE list — the compatibility contract
### DO NOT CHANGE (format & protocol — CPS-visible)
1. **SPI codeplug region bases & strides:** calibration `0x000000` (4 KB, per-unit, **never write**); settings `0x002000` bank0 + `0x003000` shadow; channels `0x004000`/48; zones `0x01C000`/48; contacts `0x05C000`(read `0x5E000`)/27; group-lists runtime `0x0C6000`/80; enc-key-names `0x0D0000`/48; msgs `0x094000`; fm `0x0D6000`; dtmf-names `0x0C7000`; addressbook `0x126000`/32; fonts `0x19C000`/`0x19E000`; unicode index `0x3F0000`.
2. **Record field layouts:** channel freq = u32 LE `MHz×100000` @ `+0x05`; contact type@+0 / id@+1 LE; 16-B `0xFF`-padded names; enums.
3. **Settings dual-bank + `0xABCD` magic @ offset `0x0C`** (bank0 `0x2000` / shadow `0x3000`). Keep the magic value and offset.
4. **USART6 CPS protocol:** ISR `0x0802061C`, framer `0x0801F864`, top dispatcher `0x08019790`, region R/W handler `0x080188D4`; buffers `0x20002EBE` / `0x200092EF` / `0x20000C5C..64`; opcodes `0x34(/0x10/0x54/0x58/0xEE)`, `0x52` (read 1 KB), `0x40`+`0x90..0xA5` region writes, `0xA4` addressbook; 8-bit-sum-seed-0 checksum.
5. **Superloop CPS branch:** the `else`-branch `0x080213B8..0x0802140A` (`0x08019A7C`, `0x0801F84C`, `0x0801F540`) and the two unconditional serial calls `serial_poll 0x0801F854` + `housekeeping 0x0801FE50`. Keep "PC Programming" mode reachable.
### REUSE (call these; don't re-implement)
- **Display §4.1**, **Input §4.2**, **Radio §4.3**, **DMR §4.4**, **Codeplug §4.5** tables above.
- **Live RAM structs:** settings working copy `0x20002014`, VFO/band `0x20002DBB` (`+1`=active band), live channel cache `0x20002DEA(+band*48)`, incoming DMR call `0x20007DC2`, DMR RX overlay `0x2000A6C5`, key struct `0x20000B57`.
**Contract:** if the rewritten UI (a) mutates only the RAM working structs and commits via the §4.5 save wrappers, and (b) leaves the §6.4/6.5 serial path byte-identical, then the on-SPI bytes remain exactly what stock produces and the stock Radtel CPS round-trips unchanged.
---
## 7. Recommended UI-rewrite architecture
### 7.1 Custom UI layer, injected into free flash
Place a custom UI layer (screen router + custom screens + a small shadow-framebuffer if desired) in **free app flash `0x08029000+`** (avoid the russification cave/font at `0x08028860/0x08028900`). It hooks the main-loop screen/key dispatch and calls the stock SDK (§4).
```
superloop 0x0802136C
└─ ui_tick_normal 0x080207DC
├─ 0x080207E6 bl ► my_draw_router (was bl 0x0801E1BC)
└─ 0x08020816 bl ► my_key_router (was bl 0x0801E6EC)
my_draw_router(): render current custom screen via draw_string/blit_cols/... (§4.1).
For not-yet-migrated screens, tail-call stock 0x0801E1BC.
my_key_router(): call keypad_process 0x08005A14 (or read KeyEv 0x20000B57 directly),
dispatch to custom screen; consume by writing 0xFF to 0x20000B59.
For not-yet-migrated screens, tail-call stock 0x0801E6EC.
```
### 7.2 The patch (2 instructions)
Each site is a Thumb BL (4 bytes). Recompute the BL immediate for the new target:
| site | stock | new |
|---|---|---|
| `0x080207E6` | `bl 0x0801E1BC` | `bl my_draw_router` |
| `0x08020816` | `bl 0x0801E6EC` | `bl my_key_router` |
Encode BL (T1) for target `T` from PC `P=site+4`: `off=(TP)`; `S=off>>24&1`; `imm10=(off>>12)&0x3FF`; `imm11=(off>>1)&0x7FF`; `J1=~(off>>23)&1 ^ ... ` — use a small assembler/capstone-keystone or the standard `bl` encoder; verify the round-trip disassembles to the intended target before flashing. (Both sites already contain a BL, so only the 4-byte immediate changes.)
### 7.3 Phased plan
1. **Phase 0 — scaffolding & recovery.** Build the injected blob; add `my_draw_router`/`my_key_router` that *tail-call the stock dispatchers unchanged*. Flash; confirm the radio behaves identically (proves the hook + relocation are correct, zero behavior change). Keep the stock `.bin` for recovery.
2. **Phase 1 — replace the standby screen (id 0).** In `my_draw_router`, `if (g_screen==0) my_home(); else stock_draw();` (Strategy A from §5). In `my_key_router`, own id-0 keys (channel up/down via `read_channel` + `radio_apply_channel`, MENU→set `g_screen=10`, PTT via `ptt_tx_start`). Everything else stock.
3. **Phase 2 — replace the menu (id 10).** Own `g_screen==10` draw+key; drive a custom menu model; read/write settings via `0x20002014` + `settings_save @0x08004CB0`. Reuse stock menu-open helper `0x0801AD9C` only if convenient.
4. **Phase 3 — per-screen migration.** Replace ids 2/3/4/5/6/8/9/11 one at a time; each stays behind an `if (g_screen==k)` guard, falling back to stock for the rest. Migrate the secondary `screen_dispatch` sub-mode (§3.4) only if a replaced screen enters it.
5. **Phase 4 — full ownership (optional).** Once all screens are custom, drop the fallbacks; optionally repoint the side-key handler `0x0801E050` and line-buffer builder `0x0801E3F8` too.
### 7.4 Keep it flashable / recoverable
- The custom layer lives **above** the stock image; the two 4-byte patches are the only edits to stock code — trivially revertible.
- **Do not** move `app_main`, the vector table, or the CPS branch. Keep **PC-Programming mode reachable** so a bad UI can still be re-flashed by the stock CPS (or the bootloader `0x39`/`0x57` path) — this is the recovery guarantee.
- Because the codeplug format is untouched, a recovery re-flash of stock firmware finds a valid codeplug and boots normally.
- Test each phase against the stock CPS (read-back + write) to confirm the on-flash format is still byte-identical.
---
## 8. Open questions / lowest-confidence items (resolve on-target)
1. **msg 0x49 semantics** (`0x08006C4C`): denoise vs radio-ID set — the two subsystem docs disagree. Prefer `atc_set_radio_id @0x080074FA` (msg 0x2A) for ID; trace 0x49's caller (channel-settings menu) on-device. (LOW)
2. **`main_settings` handle** `0x20002014` vs `0x200029BB` — confirm which is the 4 KB working copy vs a sub-pointer, and that `settings_save` stages the right one before commit. (MEDIUM)
3. **Non-digit key labels** `0x0B/0x0C/0x0D/0x10` (MENU/UP/DOWN/EXIT) and side-key combo origin (`0x11`/`0x12`) — confirm silk-screen mapping on hardware. (MEDIUM-HIGH)
4. **Secondary `screen_dispatch @0x08018DF4`** (5-entry, `ctx @0x20002121`) reachability — enumerate exactly which stock screens/sub-modes route into it so migration covers them. (MEDIUM)
5. **Group-lists `0xC6000` vs CPS `0x07C000`, enc-key-names `0xD0000` vs `0x082000`, zones 48-B vs 512-B** — firmware and `constants.py` disagree; round-trip with stock CPS before writing these regions; reuse firmware wrappers to stay stock-correct. (MEDIUM — codeplug boundary)
6. **DMR TG/Color-Code standby overlay** render sequence (`g_call @0x2000A6C5`, near `0x08006754`) — only partially traced; confirm when redesigning the RX overlay. (MEDIUM)
7. **Free-flash extent & alignment** — verify `0x08029000+` is erased/available on the actual part and pick a flash-page-aligned base for the injected blob. (LOW — mechanical)